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)]
80pub(crate) fn derive_worker_bindings(blueprint: &Blueprint) -> HashMap<String, WorkerBinding> {
81 let bound_agents = resolve_bound_agents(blueprint)
84 .expect("derive_worker_bindings requires a Blueprint with resolvable Runner refs");
85 worker_bindings_from_bound_agents(&bound_agents)
86}
87
88fn worker_bindings_from_bound_agents(
89 bound_agents: &[BoundAgent],
90) -> HashMap<String, WorkerBinding> {
91 bound_agents
92 .iter()
93 .filter_map(|bound| match &bound.runner {
94 Some(Runner::WsOperator { variant, tools })
95 | Some(Runner::WsClaudeCode { variant, tools }) => Some((
96 bound.agent.name.clone(),
97 WorkerBinding {
98 variant: variant.clone(),
99 tools: tools.clone(),
100 request_digest: Some(bound.binding_digest.clone()),
101 requested_model: bound.agent.profile.as_ref().and_then(|p| p.model.clone()),
102 },
103 )),
104 _ => None,
105 })
106 .collect()
107}
108
109async fn attest_or_gate_fresh(
124 bound_agents: &mut [BoundAgent],
125 binding_provider: Option<&dyn AgentBindingProvider>,
126 strict: bool,
127 run_ctx: Option<&RunContext>,
128) -> Result<(), TaskLaunchError> {
129 match binding_provider {
130 Some(provider) => {
131 let unbound = attest_bound_agents(provider, bound_agents, strict)
132 .await
133 .map_err(|error| TaskLaunchError::PreDispatch(error.to_string()))?;
134 for agent in &unbound {
135 record_unbound_degradation(agent, run_ctx).await;
136 }
137 Ok(())
138 }
139 None => {
140 if strict && !binding_requests(bound_agents).is_empty() {
141 return Err(TaskLaunchError::PreDispatch(format!(
142 "strict_binding requires a binding provider but none is injected; \
143 {} Runner-backed agent(s) cannot be attested",
144 binding_requests(bound_agents).len()
145 )));
146 }
147 Ok(())
148 }
149 }
150}
151
152async fn record_unbound_degradation(agent: &UnboundAgent, run_ctx: Option<&RunContext>) {
158 tracing::warn!(
159 agent = %agent.agent,
160 reason = %agent.reason,
161 "binding_unattested: agent runs DeclarationOnly (strict_binding is off)"
162 );
163 let Some(run_ctx) = run_ctx else {
164 return;
165 };
166 let entry = crate::store::run::DegradationEntry {
167 tool: "binding".to_string(),
168 error: agent.reason.clone(),
169 fallback: "DeclarationOnly".to_string(),
170 note: Some(format!(
171 "agent '{}' launched without a binding attestation (strict_binding off)",
172 agent.agent
173 )),
174 step_ref: None,
175 attempt: None,
176 at: crate::types::now_unix(),
177 };
178 if let Err(error) = run_ctx
179 .run_store
180 .append_degradation(&run_ctx.run_id, entry)
181 .await
182 {
183 tracing::warn!(
184 agent = %agent.agent,
185 %error,
186 "binding_unattested: failed to record degradation entry"
187 );
188 }
189}
190
191async fn load_or_resolve_bound_agents(
192 blueprint: &Blueprint,
193 run_ctx: Option<&RunContext>,
194 binding_provider: Option<&dyn AgentBindingProvider>,
195 legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
196) -> Result<(Vec<BoundAgent>, SnapshotOrigin), TaskLaunchError> {
197 let strict = blueprint.strategy.strict_binding;
200 let resolve_fresh = || match legacy_worker_binding_policy {
201 LegacyWorkerBindingPolicy::Allow => resolve_bound_agents(blueprint),
202 LegacyWorkerBindingPolicy::Reject => {
203 crate::blueprint::resolve_bound_agents_strict(blueprint)
204 }
205 };
206 let Some(run_ctx) = run_ctx else {
207 let mut bound_agents = resolve_fresh().map_err(CompileError::from)?;
210 attest_or_gate_fresh(&mut bound_agents, binding_provider, strict, None).await?;
211 return Ok((bound_agents, SnapshotOrigin::Launch));
212 };
213
214 let record = run_ctx
215 .run_store
216 .get(&run_ctx.run_id)
217 .await
218 .map_err(|e| TaskLaunchError::PreDispatch(format!("load Run binding snapshot: {e}")))?;
219 if let Some(input_json) = record.input_json.as_deref() {
220 let snapshot: Value = serde_json::from_str(input_json).map_err(|e| {
221 TaskLaunchError::PreDispatch(format!("decode Run launch snapshot: {e}"))
222 })?;
223 if let Some(value) = snapshot.get("bound_agents") {
224 let bound_agents: Vec<BoundAgent> =
225 serde_json::from_value(value.clone()).map_err(|e| {
226 TaskLaunchError::PreDispatch(format!("decode Run BoundAgent snapshot: {e}"))
227 })?;
228 validate_bound_agent_snapshots(&bound_agents).map_err(|error| {
229 TaskLaunchError::PreDispatch(format!("validate Run BoundAgent snapshot: {error}"))
230 })?;
231 let origin = SnapshotOrigin::from_snapshot(&snapshot);
239 return Ok((bound_agents, origin));
240 }
241 }
242
243 let mut bound_agents = resolve_fresh().map_err(CompileError::from)?;
244 attest_or_gate_fresh(&mut bound_agents, binding_provider, strict, Some(run_ctx)).await?;
245 let origin = if run_ctx.resume {
251 SnapshotOrigin::ResumeBackfill
252 } else {
253 SnapshotOrigin::Launch
254 };
255 if let Some(input_json) = record.input_json {
256 let mut snapshot: Value = serde_json::from_str(&input_json).map_err(|e| {
257 TaskLaunchError::PreDispatch(format!("decode Run launch snapshot: {e}"))
258 })?;
259 let object = snapshot.as_object_mut().ok_or_else(|| {
260 TaskLaunchError::PreDispatch("Run launch snapshot must be a JSON object".to_string())
261 })?;
262 object.insert(
267 "bound_agents".to_string(),
268 serde_json::to_value(&bound_agents).map_err(|e| {
269 TaskLaunchError::PreDispatch(format!("encode Run BoundAgent snapshot: {e}"))
270 })?,
271 );
272 object.insert(
273 crate::store::run::BOUND_AGENTS_ORIGIN_KEY.to_string(),
274 serde_json::to_value(origin).map_err(|e| {
275 TaskLaunchError::PreDispatch(format!("encode Run BoundAgent origin: {e}"))
276 })?,
277 );
278 run_ctx
279 .run_store
280 .set_input_json(
281 &run_ctx.run_id,
282 serde_json::to_string(&snapshot).map_err(|e| {
283 TaskLaunchError::PreDispatch(format!("encode Run launch snapshot: {e}"))
284 })?,
285 )
286 .await
287 .map_err(|e| {
288 TaskLaunchError::PreDispatch(format!("persist Run BoundAgent snapshot: {e}"))
289 })?;
290 }
291 if origin == SnapshotOrigin::ResumeBackfill {
295 record_backfill_degradation(run_ctx, blueprint.id.as_str()).await;
296 }
297 Ok((bound_agents, origin))
298}
299
300async fn record_backfill_degradation(run_ctx: &RunContext, blueprint_id: &str) {
307 tracing::warn!(
308 run_id = %run_ctx.run_id,
309 blueprint = %blueprint_id,
310 "binding_backfill: resumed Run had no binding snapshot; bound_agents \
311 re-derived from the current Blueprint (not a launch-time pin)"
312 );
313 let entry = crate::store::run::DegradationEntry {
314 tool: "binding".to_string(),
315 error: "run resumed without a launch-pinned binding snapshot".to_string(),
316 fallback: "resume_backfill".to_string(),
317 note: Some(format!(
318 "run '{}' backfilled bound_agents from Blueprint '{}' at resume time",
319 run_ctx.run_id, blueprint_id
320 )),
321 step_ref: None,
322 attempt: None,
323 at: crate::types::now_unix(),
324 };
325 if let Err(error) = run_ctx
326 .run_store
327 .append_degradation(&run_ctx.run_id, entry)
328 .await
329 {
330 tracing::warn!(
331 run_id = %run_ctx.run_id,
332 %error,
333 "binding_backfill: failed to record degradation entry"
334 );
335 }
336}
337
338fn derive_audits(blueprint: &Blueprint) -> Vec<AuditDef> {
347 blueprint.audits.clone()
348}
349
350pub(crate) fn derive_agent_ctx(blueprint: &Blueprint) -> (Option<Value>, HashMap<String, Value>) {
379 let global = blueprint.default_agent_ctx.clone();
380 let meta_pool = derive_step_metas(blueprint);
381 let per_agent = blueprint
382 .agents
383 .iter()
384 .filter_map(|ad| {
385 let meta = ad.meta.as_ref()?;
386 let inline = meta.ctx.clone();
387 let base = meta.meta_ref.as_ref().and_then(|name| {
388 let resolved = meta_pool.get(name).cloned();
389 if resolved.is_none() {
390 tracing::warn!(
391 agent = %ad.name,
392 meta_ref = %name,
393 "derive_agent_ctx: AgentMeta.meta_ref names an undefined Blueprint.metas entry; skipping the base layer"
394 );
395 }
396 resolved
397 });
398 let merged = match (base, inline) {
399 (None, None) => None,
400 (Some(base), None) => Some(base),
401 (None, Some(inline)) => Some(inline),
402 (Some(base), Some(inline)) => Some(shallow_merge_inline_wins(base, inline)),
403 };
404 merged.map(|ctx| (ad.name.clone(), ctx))
405 })
406 .collect();
407 (global, per_agent)
408}
409
410pub(crate) fn shallow_merge_inline_wins(base: Value, inline: Value) -> Value {
417 match (base, inline) {
418 (Value::Object(mut base), Value::Object(inline)) => {
419 for (k, v) in inline {
420 base.insert(k, v);
421 }
422 Value::Object(base)
423 }
424 (_, inline) => inline,
425 }
426}
427
428fn derive_step_metas(blueprint: &Blueprint) -> HashMap<String, Value> {
436 blueprint
437 .metas
438 .iter()
439 .map(|m| (m.name.clone(), m.ctx.clone()))
440 .collect()
441}
442
443fn derive_context_policies(
451 blueprint: &Blueprint,
452) -> (Option<ContextPolicy>, HashMap<String, ContextPolicy>) {
453 let default_policy = blueprint.default_context_policy.clone();
454 let per_agent = blueprint
455 .agents
456 .iter()
457 .filter_map(|ad| {
458 let meta = ad.meta.as_ref()?;
459 let policy = meta.context_policy.clone()?;
460 Some((ad.name.clone(), policy))
461 })
462 .collect();
463 (default_policy, per_agent)
464}
465
466fn merge_init_ctx(bp_default: Option<&Value>, task_init_ctx: &Value) -> Value {
482 match (bp_default, task_init_ctx) {
483 (Some(Value::Object(bp_map)), Value::Object(task_map)) => {
484 let mut merged = bp_map.clone();
485 for (k, v) in task_map {
486 merged.insert(k.clone(), v.clone());
487 }
488 Value::Object(merged)
489 }
490 (None, _) => task_init_ctx.clone(),
491 (_, task) => task.clone(),
492 }
493}
494
495pub fn merge_init_ctx_3layer(
511 bp_default: Option<&Value>,
512 task_init_ctx: &Value,
513 run_override: Option<&Value>,
514) -> Value {
515 let bp_task = merge_init_ctx(bp_default, task_init_ctx);
516 match run_override {
517 Some(run) => merge_init_ctx(Some(&bp_task), run),
518 None => bp_task,
519 }
520}
521
522fn derive_bp_agent_kinds(blueprint: &Blueprint) -> HashMap<String, OperatorKind> {
523 let mut out = HashMap::new();
524 if blueprint.operators.is_empty() {
525 return out;
526 }
527 for agent in &blueprint.agents {
528 let Some(op_ref) = agent.spec.get("operator_ref").and_then(|v| v.as_str()) else {
529 continue;
530 };
531 let Some(op_def) = blueprint.operators.iter().find(|o| o.name == op_ref) else {
532 continue;
533 };
534 if let Some(kind) = op_def.kind {
535 out.insert(agent.name.clone(), OperatorKind::from(kind));
536 }
537 }
538 out
539}
540
541#[derive(Debug, Error)]
543pub enum TaskLaunchError {
544 #[error("compile: {0}")]
546 Compile(#[from] CompileError),
547 #[error("engine: {0}")]
549 Engine(#[from] EngineError),
550 #[error("flow eval: {message}")]
566 FlowEval {
567 message: String,
570 failed_step: Option<String>,
575 verdict_value: Option<Value>,
579 partial_ctx: Option<Value>,
587 },
588 #[error("pre-dispatch: {0}")]
595 PreDispatch(String),
596}
597
598#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
621pub struct TaskInputSpec {
622 #[serde(default)]
624 pub project_root: Option<String>,
625 #[serde(default)]
627 pub work_dir: Option<String>,
628 #[serde(default)]
630 #[schemars(with = "Option<Value>")]
631 pub task_metadata: Option<Value>,
632}
633
634#[derive(Debug, Clone)]
636pub struct TaskLaunchInput {
637 pub blueprint: Blueprint,
639 pub operator_id: String,
641 pub role: Role,
643 pub ttl: Duration,
645 pub operator_kind: Option<OperatorKind>,
654 pub bridge_id: Option<String>,
658 pub hook_id: Option<String>,
661 pub operator_sid: Option<String>,
702 pub operator_kind_overrides: HashMap<String, OperatorKind>,
707 pub init_ctx: Value,
712 pub task_input: Option<TaskInputSpec>,
719 pub run_ctx: Option<RunContext>,
726 pub check_policy: Option<CheckPolicy>,
749}
750
751impl TaskLaunchInput {
752 pub fn automate(
762 blueprint: Blueprint,
763 operator_id: impl Into<String>,
764 role: Role,
765 ttl: Duration,
766 init_ctx: Value,
767 ) -> Self {
768 Self {
769 blueprint,
770 operator_id: operator_id.into(),
771 role,
772 ttl,
773 operator_kind: None,
774 bridge_id: None,
775 hook_id: None,
776 operator_sid: None,
777 operator_kind_overrides: HashMap::new(),
778 init_ctx,
779 task_input: None,
780 run_ctx: None,
781 check_policy: None,
782 }
783 }
784}
785
786#[derive(Debug, Clone)]
788pub struct TaskLaunchOutput {
789 pub token: CapToken,
791 pub final_ctx: Value,
795}
796
797pub struct TaskLaunchService {
801 engine: Engine,
802 compiler: Compiler,
803 externs: Arc<dyn Externs + Send + Sync>,
808 binding_provider: Option<Arc<dyn AgentBindingProvider>>,
811 legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
814}
815
816impl TaskLaunchService {
817 pub fn new(engine: Engine, compiler: Compiler) -> Self {
819 Self {
820 engine,
821 compiler,
822 externs: Arc::new(NoExterns),
823 binding_provider: None,
824 legacy_worker_binding_policy: LegacyWorkerBindingPolicy::default(),
825 }
826 }
827
828 pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
832 self.externs = externs;
833 self
834 }
835
836 pub fn with_binding_provider(mut self, provider: Arc<dyn AgentBindingProvider>) -> Self {
840 self.binding_provider = Some(provider);
841 self
842 }
843
844 pub fn with_legacy_worker_binding_policy(mut self, policy: LegacyWorkerBindingPolicy) -> Self {
846 self.legacy_worker_binding_policy = policy;
847 self
848 }
849
850 pub fn engine(&self) -> &Engine {
852 &self.engine
853 }
854
855 pub fn compiler(&self) -> &Compiler {
857 &self.compiler
858 }
859
860 pub async fn launch(
872 &self,
873 mut input: TaskLaunchInput,
874 ) -> Result<TaskLaunchOutput, TaskLaunchError> {
875 let pinned_binding_provider: Option<Arc<dyn AgentBindingProvider>> = input
889 .operator_sid
890 .as_deref()
891 .and_then(|pin| self.binding_provider.as_ref()?.pinned_to_session(pin));
892 let binding_provider = pinned_binding_provider
893 .as_deref()
894 .or(self.binding_provider.as_deref());
895 let (bound_agents, snapshot_origin) = load_or_resolve_bound_agents(
896 &input.blueprint,
897 input.run_ctx.as_ref(),
898 binding_provider,
899 self.legacy_worker_binding_policy,
900 )
901 .await?;
902 let binding_digests: HashMap<String, crate::blueprint::BindingDigest> = bound_agents
903 .iter()
904 .map(|bound| (bound.agent.name.clone(), bound.binding_digest.clone()))
905 .collect();
906 if let Some(run_ctx) = input.run_ctx.take() {
907 input.run_ctx = Some(match snapshot_origin {
918 SnapshotOrigin::Launch => run_ctx.with_binding_digests(binding_digests.clone()),
919 SnapshotOrigin::ResumeBackfill => run_ctx,
920 });
921 }
922 input.blueprint = materialize_bound_blueprint(&input.blueprint, &bound_agents);
923 let compiled = self
924 .compiler
925 .compile_bound(&input.blueprint, &bound_agents)?;
926 self.engine
935 .register_verdict_contracts(compiled.router.verdict_contracts.clone());
936 let spawner = linker::link(
937 compiled.router.clone(),
938 &input.blueprint.spawner_hints.layers,
939 &self.engine,
940 );
941 let (agent_ctx_global, agent_ctx_per_agent) = derive_agent_ctx(&input.blueprint);
957 let (context_policy_default, context_policy_per_agent) =
958 derive_context_policies(&input.blueprint);
959 let spawner = SpawnerStack::new(spawner)
960 .layer(AgentContextMiddleware::new(
961 agent_ctx_global,
962 agent_ctx_per_agent,
963 context_policy_default,
964 context_policy_per_agent,
965 ))
966 .build();
967 let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
974 SpawnerStack::new(spawner)
975 .layer(ProjectNameAliasMiddleware::new(alias))
976 .build()
977 } else {
978 spawner
979 };
980 let worker_bindings = worker_bindings_from_bound_agents(&bound_agents);
987 let spawner = if worker_bindings.is_empty() {
988 spawner
989 } else {
990 SpawnerStack::new(spawner)
991 .layer(WorkerBindingMiddleware::new(worker_bindings))
992 .build()
993 };
994 let audit_defs = derive_audits(&input.blueprint);
1004 let spawner = if audit_defs.is_empty() {
1005 spawner
1006 } else {
1007 SpawnerStack::new(spawner)
1008 .layer(AfterRunAuditMiddleware::new(
1009 audit_defs,
1010 compiled.router.clone(),
1011 ))
1012 .build()
1013 };
1014
1015 let spawner = match input.task_input.as_ref().and_then(|spec| {
1022 TaskInputMiddleware::new_from_fields(
1023 spec.project_root.clone(),
1024 spec.work_dir.clone(),
1025 spec.task_metadata.clone(),
1026 )
1027 }) {
1028 Some(task_input) => SpawnerStack::new(spawner).layer(task_input).build(),
1029 None => spawner,
1030 };
1031
1032 let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
1037 let bp_global_kind = input
1038 .blueprint
1039 .default_operator_kind
1040 .map(OperatorKind::from);
1041
1042 let token = self
1052 .engine
1053 .attach_with_ids(
1054 input.operator_id,
1055 input.role,
1056 input.ttl,
1057 input.operator_kind,
1058 input.bridge_id,
1059 input.hook_id,
1060 input.operator_sid,
1061 input.operator_kind_overrides,
1062 bp_agent_kinds,
1063 bp_global_kind,
1064 )
1065 .await?;
1066 let resolved_check_policy = input.check_policy.or(input.blueprint.check_policy);
1077 let effective_check_policy =
1092 resolved_check_policy.unwrap_or(self.engine.cfg().check_policy);
1093 if effective_check_policy == CheckPolicy::Strict {
1094 let roots_missing = input
1095 .task_input
1096 .as_ref()
1097 .map(|t| t.project_root.is_none() && t.work_dir.is_none())
1098 .unwrap_or(true);
1099 if roots_missing {
1100 return Err(TaskLaunchError::PreDispatch(
1101 "check_policy=strict requires project_root or work_dir, but the launch \
1102 supplied neither"
1103 .to_string(),
1104 ));
1105 }
1106 }
1107 let dispatcher =
1108 EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
1109 let dispatcher = dispatcher.with_check_policy(resolved_check_policy);
1110 let map_err_run_ctx = input.run_ctx.clone();
1118 let dispatcher = match input.run_ctx {
1119 Some(run_ctx) => dispatcher.with_run(run_ctx),
1120 None => dispatcher,
1121 };
1122 let dispatcher = dispatcher.with_step_metas(derive_step_metas(&input.blueprint));
1126 let dispatcher = dispatcher.with_binding_digests(binding_digests);
1127 let dispatcher = dispatcher.with_concurrency_limits(
1130 input.blueprint.id.as_str(),
1131 input
1132 .blueprint
1133 .agents
1134 .iter()
1135 .filter_map(|a| a.max_concurrency.map(|n| (a.name.clone(), n)))
1136 .collect(),
1137 );
1138 let dispatcher = dispatcher.with_step_naming(compiled.step_naming.clone());
1144 let dispatcher =
1151 dispatcher.with_projection_placement(compiled.projection_placement.clone());
1152 let merged_init_ctx =
1158 merge_init_ctx(input.blueprint.default_init_ctx.as_ref(), &input.init_ctx);
1159 let eval_result = mlua_flow_ir::eval_async_externs(
1160 &input.blueprint.flow,
1161 merged_init_ctx,
1162 &dispatcher,
1163 &*self.externs,
1164 )
1165 .await;
1166 let final_ctx = match eval_result {
1167 Ok(v) => v,
1168 Err(e) => {
1169 let (failed_step, verdict_value) = match &map_err_run_ctx {
1189 Some(rc) => {
1190 let slot = rc.last_failure.lock().ok().and_then(|g| g.clone());
1191 match slot {
1192 Some(lf) => (
1193 lf.step_ref.clone().or_else(|| Some(lf.step_id.to_string())),
1194 Some(lf.verdict_value.clone()),
1195 ),
1196 None => (None, None),
1197 }
1198 }
1199 None => (None, None),
1200 };
1201 let partial_ctx = match &map_err_run_ctx {
1202 Some(rc) => Some(rc.snapshot_partial_ctx().await),
1203 None => None,
1204 };
1205 return Err(TaskLaunchError::FlowEval {
1206 message: e.to_string(),
1207 failed_step,
1208 verdict_value,
1209 partial_ctx,
1210 });
1211 }
1212 };
1213 Ok(TaskLaunchOutput { token, final_ctx })
1214 }
1215}
1216
1217#[cfg(test)]
1222mod tests {
1223 use super::*;
1224 use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
1225 use crate::blueprint::{
1226 current_schema_version, resolve_runner, AgentDef, AgentKind, AgentMeta, AgentProfile,
1227 BlueprintMetadata, CompilerHints, CompilerStrategy, MetaDef, Runner,
1228 };
1229 use crate::core::config::EngineCfg;
1230 use crate::worker::adapter::{WorkerError, WorkerResult};
1231 use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
1232 use serde_json::json;
1233 use std::sync::Arc;
1234
1235 fn path(s: &str) -> Expr {
1236 Expr::Path {
1237 at: s.parse().expect("literal test path"),
1238 }
1239 }
1240 fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
1241 FlowNode::Step {
1242 ref_: ref_.to_string(),
1243 in_,
1244 out,
1245 }
1246 }
1247
1248 fn agent(name: &str, fn_id: &str) -> AgentDef {
1249 AgentDef {
1250 name: name.to_string(),
1251 kind: AgentKind::RustFn,
1252 spec: json!({ "fn_id": fn_id }),
1253 profile: None,
1254 meta: Some(AgentMeta::default()),
1255 runner: None,
1256 runner_ref: None,
1257 verdict: None,
1258 lints: None,
1259 max_concurrency: None,
1260 }
1261 }
1262
1263 fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
1264 let engine = Engine::new(EngineCfg::default());
1265 let mut reg = SpawnerRegistry::new();
1266 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1267 let compiler = Compiler::new(reg);
1268 TaskLaunchService::new(engine, compiler)
1269 }
1270
1271 fn build_service_with_cfg(
1275 factory: RustFnInProcessSpawnerFactory,
1276 cfg: EngineCfg,
1277 ) -> TaskLaunchService {
1278 let engine = Engine::new(cfg);
1279 let mut reg = SpawnerRegistry::new();
1280 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1281 let compiler = Compiler::new(reg);
1282 TaskLaunchService::new(engine, compiler)
1283 }
1284
1285 fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
1286 Blueprint {
1287 schema_version: current_schema_version(),
1288 id: "ut".into(),
1289 flow,
1290 agents,
1291 operators: vec![],
1292 metas: vec![],
1293 hints: CompilerHints::default(),
1294 strategy: CompilerStrategy::default(),
1295 metadata: BlueprintMetadata::default(),
1296 spawner_hints: Default::default(),
1297 default_agent_kind: AgentKind::Operator,
1298 default_operator_kind: None,
1299 default_init_ctx: None,
1300 default_agent_ctx: None,
1301 default_context_policy: None,
1302 projection_placement: None,
1303 audits: vec![],
1304 degradation_policy: None,
1305 runners: vec![],
1306 default_runner: None,
1307 subprocesses: vec![],
1308 check_policy: None,
1309 blueprint_ref_includes: Vec::new(),
1310 }
1311 }
1312
1313 fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
1314 TaskLaunchInput::automate(
1315 blueprint,
1316 "ut-op",
1317 Role::Operator,
1318 Duration::from_secs(30),
1319 init_ctx,
1320 )
1321 }
1322
1323 #[test]
1329 fn derive_audits_empty_by_default() {
1330 let blueprint = bp(
1331 step("echo", path("$.input"), path("$.out")),
1332 vec![agent("echo", "echo")],
1333 );
1334 assert!(
1335 derive_audits(&blueprint).is_empty(),
1336 "audits_absent_no_layer: an undeclared audits Vec must stay empty"
1337 );
1338 }
1339
1340 #[test]
1341 fn derive_audits_returns_blueprint_audits_verbatim() {
1342 let mut blueprint = bp(
1343 step("echo", path("$.input"), path("$.out")),
1344 vec![agent("echo", "echo")],
1345 );
1346 blueprint.audits = vec![crate::blueprint::AuditDef {
1347 agent: "auditor".to_string(),
1348 steps: None,
1349 mode: crate::blueprint::AuditMode::Async,
1350 }];
1351 let got = derive_audits(&blueprint);
1352 assert_eq!(got.len(), 1);
1353 assert_eq!(got[0].agent, "auditor");
1354 }
1355
1356 #[tokio::test]
1357 async fn launch_appends_audit_artifact_when_audits_declared() {
1358 use crate::blueprint::{AuditDef, AuditMode};
1359
1360 let factory = RustFnInProcessSpawnerFactory::new()
1361 .register_fn("echo", |inv| async move {
1362 Ok(WorkerResult {
1363 value: json!({ "echoed": inv.prompt }),
1364 ok: true,
1365 stats: None,
1366 })
1367 })
1368 .register_fn("audit-fn", |_inv| async move {
1369 Ok(WorkerResult {
1370 value: json!({ "finding": "clean" }),
1371 ok: true,
1372 stats: None,
1373 })
1374 });
1375 let svc = build_service(factory);
1376 let mut blueprint = bp(
1377 step("echo", path("$.input"), path("$.out")),
1378 vec![agent("echo", "echo"), agent("auditor", "audit-fn")],
1379 );
1380 blueprint.audits = vec![AuditDef {
1381 agent: "auditor".to_string(),
1382 steps: None,
1383 mode: AuditMode::Sync,
1384 }];
1385 let out = svc
1386 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1387 .await
1388 .expect("launch ok — audits must never alter the audited step's outcome");
1389 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1390
1391 let audited_task_id = svc
1392 .engine()
1393 .with_state("test.find_audited_task", |s| {
1394 s.tasks
1395 .iter()
1396 .find(|(_, t)| t.spec.agent == "echo")
1397 .map(|(id, _)| id.clone())
1398 })
1399 .await
1400 .expect("with_state")
1401 .expect("the echo task must exist");
1402 let tail = svc.engine().output_tail(&audited_task_id, 1).await;
1403 let found = tail.iter().any(|ev| {
1404 matches!(
1405 ev,
1406 crate::worker::output::OutputEvent::Artifact { name, .. } if name == "audit:echo"
1407 )
1408 });
1409 assert!(
1410 found,
1411 "launch() must wire AfterRunAuditMiddleware end-to-end when Blueprint.audits is declared"
1412 );
1413 }
1414
1415 #[tokio::test]
1416 async fn launch_single_step_writes_out_path() {
1417 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1418 Ok(WorkerResult {
1419 value: json!({ "echoed": inv.prompt }),
1420 ok: true,
1421 stats: None,
1422 })
1423 });
1424 let svc = build_service(factory);
1425 let blueprint = bp(
1426 step("echo", path("$.input"), path("$.out")),
1427 vec![agent("echo", "echo")],
1428 );
1429 let out = svc
1430 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1431 .await
1432 .expect("launch ok");
1433 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1434 }
1435
1436 async fn dispatched_check_policy(
1457 launch_policy: Option<CheckPolicy>,
1458 bp_policy: Option<CheckPolicy>,
1459 ) -> Option<CheckPolicy> {
1460 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1461 Ok(WorkerResult {
1462 value: json!({ "echoed": inv.prompt }),
1463 ok: true,
1464 stats: None,
1465 })
1466 });
1467 let svc = build_service(factory);
1468 let mut blueprint = bp(
1469 step("echo", path("$.input"), path("$.out")),
1470 vec![agent("echo", "echo")],
1471 );
1472 blueprint.check_policy = bp_policy;
1473 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1474 input.check_policy = launch_policy;
1475 input.task_input = Some(TaskInputSpec {
1476 project_root: None,
1477 work_dir: Some("/dispatched-check-policy-test-root".to_string()),
1478 task_metadata: None,
1479 });
1480 let _ = svc.launch(input).await;
1481 svc.engine()
1482 .with_state("test.read_dispatched_check_policy", |s| {
1483 s.tasks
1484 .values()
1485 .find(|t| t.spec.agent == "echo")
1486 .and_then(|t| t.spec.check_policy)
1487 })
1488 .await
1489 .expect("with_state")
1490 }
1491
1492 #[tokio::test]
1495 async fn cascade_launch_tier_wins_over_blueprint_tier() {
1496 assert_eq!(
1497 dispatched_check_policy(Some(CheckPolicy::Silent), Some(CheckPolicy::Strict)).await,
1498 Some(CheckPolicy::Silent),
1499 );
1500 }
1501
1502 #[tokio::test]
1505 async fn cascade_blueprint_tier_used_when_launch_absent() {
1506 assert_eq!(
1507 dispatched_check_policy(None, Some(CheckPolicy::Strict)).await,
1508 Some(CheckPolicy::Strict),
1509 );
1510 }
1511
1512 #[tokio::test]
1515 async fn cascade_launch_tier_alone_when_blueprint_absent() {
1516 assert_eq!(
1517 dispatched_check_policy(Some(CheckPolicy::Strict), None).await,
1518 Some(CheckPolicy::Strict),
1519 );
1520 }
1521
1522 #[tokio::test]
1527 async fn cascade_both_none_preserves_server_fallback() {
1528 assert_eq!(dispatched_check_policy(None, None).await, None);
1529 }
1530
1531 #[tokio::test]
1547 async fn strict_blueprint_without_roots_is_rejected_pre_dispatch() {
1548 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1549 Ok(WorkerResult {
1550 value: json!({ "echoed": inv.prompt }),
1551 ok: true,
1552 stats: None,
1553 })
1554 });
1555 let svc = build_service(factory);
1556 let mut blueprint = bp(
1557 step("echo", path("$.input"), path("$.out")),
1558 vec![agent("echo", "echo")],
1559 );
1560 blueprint.check_policy = Some(CheckPolicy::Strict);
1561 let err = svc
1563 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1564 .await
1565 .expect_err("strict check_policy + no roots must be rejected before dispatch");
1566 match err {
1567 TaskLaunchError::PreDispatch(message) => {
1568 assert!(
1569 message.contains("strict"),
1570 "message must identify the strict-requires-roots condition: {message}"
1571 );
1572 }
1573 other => panic!("expected TaskLaunchError::PreDispatch, got {other:?}"),
1574 }
1575
1576 let dispatched = svc
1580 .engine()
1581 .with_state("test.no_echo_task_dispatched", |s| {
1582 s.tasks.values().any(|t| t.spec.agent == "echo")
1583 })
1584 .await
1585 .expect("with_state");
1586 assert!(
1587 !dispatched,
1588 "the pre-dispatch guard must reject before any step is dispatched"
1589 );
1590 }
1591
1592 #[tokio::test]
1604 async fn launch_without_any_check_policy_completes_fail_open() {
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(factory);
1613 let blueprint = bp(
1614 step("echo", path("$.input"), path("$.out")),
1615 vec![agent("echo", "echo")],
1616 );
1617 assert_eq!(blueprint.check_policy, None, "BP tier must be unset");
1618 let out = svc
1619 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1620 .await
1621 .expect("warn-mode fail-open must let the launch complete");
1622 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1623 }
1624
1625 #[tokio::test]
1646 async fn strict_blueprint_with_launch_warn_override_bypasses_pre_dispatch_guard() {
1647 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1648 Ok(WorkerResult {
1649 value: json!({ "echoed": inv.prompt }),
1650 ok: true,
1651 stats: None,
1652 })
1653 });
1654 let svc = build_service(factory);
1655 let mut blueprint = bp(
1656 step("echo", path("$.input"), path("$.out")),
1657 vec![agent("echo", "echo")],
1658 );
1659 blueprint.check_policy = Some(CheckPolicy::Strict);
1660 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1661 input.check_policy = Some(CheckPolicy::Warn);
1662 assert!(input.task_input.is_none(), "no roots supplied at all");
1663 let out = svc
1664 .launch(input)
1665 .await
1666 .expect("launch-tier warn override must bypass the pre-dispatch guard");
1667 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1668 }
1669
1670 #[tokio::test]
1676 async fn server_tier_strict_alone_triggers_pre_dispatch_guard() {
1677 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1678 Ok(WorkerResult {
1679 value: json!({ "echoed": inv.prompt }),
1680 ok: true,
1681 stats: None,
1682 })
1683 });
1684 let svc = build_service_with_cfg(
1685 factory,
1686 EngineCfg {
1687 check_policy: CheckPolicy::Strict,
1688 ..EngineCfg::default()
1689 },
1690 );
1691 let blueprint = bp(
1692 step("echo", path("$.input"), path("$.out")),
1693 vec![agent("echo", "echo")],
1694 );
1695 assert_eq!(blueprint.check_policy, None, "BP tier must be unset");
1696 let input = launch_input(blueprint, json!({ "input": "hi" }));
1697 assert!(input.check_policy.is_none(), "launch tier must be unset");
1698 assert!(input.task_input.is_none(), "no roots supplied");
1699 let err = svc.launch(input).await.expect_err(
1700 "server-tier Strict alone (BP/launch tiers both unset) must trigger the guard",
1701 );
1702 match err {
1703 TaskLaunchError::PreDispatch(message) => {
1704 assert!(
1705 message.contains("strict"),
1706 "expected the strict-requires-roots message, got: {message}"
1707 );
1708 }
1709 other => panic!("expected TaskLaunchError::PreDispatch, got {other:?}"),
1710 }
1711 }
1712
1713 #[tokio::test]
1719 async fn pre_dispatch_guard_rejects_when_task_input_present_but_roots_both_none() {
1720 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1721 Ok(WorkerResult {
1722 value: json!({ "echoed": inv.prompt }),
1723 ok: true,
1724 stats: None,
1725 })
1726 });
1727 let svc = build_service(factory);
1728 let mut blueprint = bp(
1729 step("echo", path("$.input"), path("$.out")),
1730 vec![agent("echo", "echo")],
1731 );
1732 blueprint.check_policy = Some(CheckPolicy::Strict);
1733 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1734 input.task_input = Some(TaskInputSpec {
1735 project_root: None,
1736 work_dir: None,
1737 task_metadata: Some(json!({ "unrelated": true })),
1738 });
1739 let err = svc
1740 .launch(input)
1741 .await
1742 .expect_err("Some(TaskInputSpec) with both roots None must still be roots_missing");
1743 assert!(
1744 matches!(err, TaskLaunchError::PreDispatch(_)),
1745 "expected TaskLaunchError::PreDispatch, got {err:?}"
1746 );
1747 }
1748
1749 #[tokio::test]
1754 async fn pre_dispatch_guard_passes_when_work_dir_present_and_project_root_absent() {
1755 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1756 Ok(WorkerResult {
1757 value: json!({ "echoed": inv.prompt }),
1758 ok: true,
1759 stats: None,
1760 })
1761 });
1762 let svc = build_service(factory);
1763 let mut blueprint = bp(
1764 step("echo", path("$.input"), path("$.out")),
1765 vec![agent("echo", "echo")],
1766 );
1767 blueprint.check_policy = Some(CheckPolicy::Strict);
1768 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1769 input.task_input = Some(TaskInputSpec {
1770 project_root: None,
1771 work_dir: Some("/repo/work".to_string()),
1772 task_metadata: None,
1773 });
1774 let out = svc
1775 .launch(input)
1776 .await
1777 .expect("work_dir alone must satisfy the guard's roots_missing check");
1778 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1779 }
1780
1781 #[tokio::test]
1782 async fn launch_three_step_seq_threads_ctx_forward() {
1783 let factory = RustFnInProcessSpawnerFactory::new()
1784 .register_fn("upper", |inv| async move {
1785 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1786 Ok(WorkerResult {
1787 value: json!(s.to_uppercase()),
1788 ok: true,
1789 stats: None,
1790 })
1791 })
1792 .register_fn("suffix", |inv| async move {
1793 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1794 Ok(WorkerResult {
1795 value: json!(format!("{s}!")),
1796 ok: true,
1797 stats: None,
1798 })
1799 })
1800 .register_fn("wrap", |inv| async move {
1801 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1802 Ok(WorkerResult {
1803 value: json!(format!("[{s}]")),
1804 ok: true,
1805 stats: None,
1806 })
1807 });
1808 let svc = build_service(factory);
1809 let flow = FlowNode::Seq {
1810 children: vec![
1811 step("upper", path("$.in"), path("$.s1")),
1812 step("suffix", path("$.s1"), path("$.s2")),
1813 step("wrap", path("$.s2"), path("$.s3")),
1814 ],
1815 };
1816 let blueprint = bp(
1817 flow,
1818 vec![
1819 agent("upper", "upper"),
1820 agent("suffix", "suffix"),
1821 agent("wrap", "wrap"),
1822 ],
1823 );
1824 let out = svc
1825 .launch(launch_input(blueprint, json!({ "in": "hello" })))
1826 .await
1827 .expect("launch ok");
1828 assert_eq!(out.final_ctx["s1"], "HELLO");
1829 assert_eq!(out.final_ctx["s2"], "HELLO!");
1830 assert_eq!(out.final_ctx["s3"], "[HELLO!]");
1831 }
1832
1833 #[tokio::test]
1834 async fn launch_fanout_join_all_parallel_completes() {
1835 use std::sync::atomic::{AtomicU32, Ordering};
1836 let counter = Arc::new(AtomicU32::new(0));
1837 let max_seen = Arc::new(AtomicU32::new(0));
1838 let counter_clone = counter.clone();
1839 let max_clone = max_seen.clone();
1840
1841 let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
1844 let counter = counter_clone.clone();
1845 let max_seen = max_clone.clone();
1846 async move {
1847 let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
1848 let mut prev = max_seen.load(Ordering::SeqCst);
1849 while now > prev {
1850 match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
1851 Ok(_) => break,
1852 Err(p) => prev = p,
1853 }
1854 }
1855 tokio::time::sleep(Duration::from_millis(50)).await;
1856 counter.fetch_sub(1, Ordering::SeqCst);
1857 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1858 Ok(WorkerResult {
1859 value: json!(format!("did:{s}")),
1860 ok: true,
1861 stats: None,
1862 })
1863 }
1864 });
1865 let svc = build_service(factory);
1866 let flow = FlowNode::Fanout {
1867 items: path("$.items"),
1868 bind: path("$.item"),
1869 body: Box::new(step("para", path("$.item"), path("$.r"))),
1870 join: JoinMode::All,
1871 out: path("$.results"),
1872 };
1873 let blueprint = bp(flow, vec![agent("para", "para")]);
1874 let out = svc
1875 .launch(launch_input(
1876 blueprint,
1877 json!({ "items": ["a", "b", "c", "d"] }),
1878 ))
1879 .await
1880 .expect("launch ok");
1881 let results = out.final_ctx["results"].as_array().expect("array");
1882 assert_eq!(results.len(), 4);
1883 for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
1884 assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
1885 }
1886 let max = max_seen.load(Ordering::SeqCst);
1887 assert!(
1888 max >= 2,
1889 "expected parallel execution (max inflight >= 2), got {max}"
1890 );
1891 }
1892
1893 #[tokio::test]
1894 async fn launch_propagates_worker_error_as_flow_eval_err() {
1895 let factory = RustFnInProcessSpawnerFactory::new()
1896 .register_fn("ok", |inv| async move {
1897 Ok(WorkerResult {
1898 value: json!(inv.prompt),
1899 ok: true,
1900 stats: None,
1901 })
1902 })
1903 .register_fn("boom", |_inv| async move {
1904 Err(WorkerError::Failed("intentional boom".into()))
1905 });
1906 let svc = build_service(factory);
1907 let flow = FlowNode::Seq {
1908 children: vec![
1909 step("ok", path("$.input"), path("$.s1")),
1910 step("boom", path("$.s1"), path("$.s2")),
1911 step("ok", path("$.s2"), path("$.s3")),
1912 ],
1913 };
1914 let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
1915 let err = svc
1916 .launch(launch_input(blueprint, json!({ "input": "x" })))
1917 .await
1918 .expect_err("expected fail");
1919 match err {
1920 TaskLaunchError::FlowEval { message: msg, .. } => {
1921 assert!(
1922 msg.contains("boom") || msg.contains("intentional"),
1923 "expected error to mention worker failure, got: {msg}"
1924 );
1925 }
1926 other => panic!("expected FlowEval error, got {other:?}"),
1927 }
1928 }
1929
1930 #[tokio::test]
1931 async fn launch_resolves_call_extern_via_registered_externs() {
1932 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1933 Ok(WorkerResult {
1934 value: json!({ "echoed": inv.prompt }),
1935 ok: true,
1936 stats: None,
1937 })
1938 });
1939 let mut externs = mlua_flow_ir::ExternMap::new();
1940 externs.register("fmt.greet", |args: &[Value]| {
1941 let name = args[0].as_str().unwrap_or("?");
1942 Ok(json!(format!("hello, {name}")))
1943 });
1944 let svc = build_service(factory).with_externs(Arc::new(externs));
1945 let flow = step(
1946 "echo",
1947 Expr::CallExtern {
1948 ref_: "fmt.greet".into(),
1949 args: vec![path("$.who")],
1950 },
1951 path("$.out"),
1952 );
1953 let blueprint = bp(flow, vec![agent("echo", "echo")]);
1954 let out = svc
1955 .launch(launch_input(blueprint, json!({ "who": "swarm" })))
1956 .await
1957 .expect("launch ok");
1958 assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
1959 }
1960
1961 #[tokio::test]
1962 async fn launch_call_extern_without_registry_fails_as_flow_eval() {
1963 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1964 Ok(WorkerResult {
1965 value: json!(inv.prompt),
1966 ok: true,
1967 stats: None,
1968 })
1969 });
1970 let svc = build_service(factory); let flow = step(
1972 "echo",
1973 Expr::CallExtern {
1974 ref_: "fmt.greet".into(),
1975 args: vec![],
1976 },
1977 path("$.out"),
1978 );
1979 let blueprint = bp(flow, vec![agent("echo", "echo")]);
1980 let err = svc
1981 .launch(launch_input(blueprint, json!({})))
1982 .await
1983 .expect_err("expected fail");
1984 match err {
1985 TaskLaunchError::FlowEval { message: msg, .. } => {
1986 assert!(msg.contains("extern"), "expected extern error, got: {msg}");
1987 }
1988 other => panic!("expected FlowEval error, got {other:?}"),
1989 }
1990 }
1991
1992 #[tokio::test]
2012 async fn launch_registers_the_blueprints_verdict_contracts_into_the_engine() {
2013 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |inv| async move {
2014 Ok(WorkerResult {
2015 value: json!(inv.prompt),
2016 ok: true,
2017 stats: None,
2018 })
2019 });
2020 let svc = build_service(factory);
2021 let mut gate_agent = agent("gate", "gate");
2022 gate_agent.verdict = Some(mlua_swarm_schema::VerdictContract {
2023 channel: mlua_swarm_schema::VerdictChannel::Body,
2024 values: vec!["PASS".to_string(), "BLOCKED".to_string()],
2025 });
2026 let flow = step("gate", path("$.input"), path("$.out"));
2027 let blueprint = bp(flow, vec![gate_agent]);
2028
2029 let out = svc
2030 .launch(launch_input(blueprint, json!({ "input": "PASS" })))
2031 .await
2032 .expect("launch ok");
2033 assert_eq!(out.final_ctx["out"], json!("PASS"));
2034
2035 let task_id = svc
2040 .engine()
2041 .with_state("test.find_dispatched_task_id", |s| {
2042 s.tasks.keys().next().cloned()
2043 })
2044 .await
2045 .expect("with_state")
2046 .expect("launch must have dispatched exactly one Step (one TaskState)");
2047
2048 let contract = svc
2049 .engine()
2050 .verdict_contract_for_task(&task_id)
2051 .await
2052 .expect(
2053 "TaskLaunchService::launch must have merged this Blueprint's compiled \
2054 verdict_contracts into the engine's runtime registry \
2055 (Engine::register_verdict_contracts, called right after \
2056 compiler.compile succeeds) — verdict_contract_for_task resolving None \
2057 here means that production wiring regressed",
2058 );
2059 assert_eq!(contract.channel, mlua_swarm_schema::VerdictChannel::Body);
2060 assert_eq!(
2061 contract.values,
2062 vec!["PASS".to_string(), "BLOCKED".to_string()]
2063 );
2064 }
2065
2066 #[tokio::test]
2071 async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
2072 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2073 use crate::types::{RunId, TaskId};
2074
2075 let factory = RustFnInProcessSpawnerFactory::new()
2076 .register_fn("upper", |inv| async move {
2077 Ok(WorkerResult {
2078 value: json!(inv.prompt.to_uppercase()),
2079 ok: true,
2080 stats: None,
2081 })
2082 })
2083 .register_fn("suffix", |inv| async move {
2084 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
2085 Ok(WorkerResult {
2086 value: json!(format!("{s}!")),
2087 ok: true,
2088 stats: None,
2089 })
2090 });
2091 let svc = build_service(factory);
2092 let flow = FlowNode::Seq {
2093 children: vec![
2094 step("upper", path("$.in"), path("$.s1")),
2095 step("suffix", path("$.s1"), path("$.s2")),
2096 ],
2097 };
2098 let blueprint = bp(
2099 flow,
2100 vec![agent("upper", "upper"), agent("suffix", "suffix")],
2101 );
2102
2103 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2104 let run_id = RunId::new();
2105 run_store
2106 .create(RunRecord {
2107 id: run_id.clone(),
2108 task_id: TaskId::new(),
2109 status: RunStatus::Running,
2110 step_entries: Vec::new(),
2111 degradations: Vec::new(),
2112 operator_sid: None,
2113 current: Default::default(),
2114 next_generation: 0,
2115 result_ref: None,
2116 input_json: Some("{}".to_string()),
2117 created_at: 0,
2118 updated_at: 0,
2119 })
2120 .await
2121 .expect("seed RunRecord");
2122
2123 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
2124 input.run_ctx = Some(RunContext::new(run_id.clone(), run_store.clone()));
2125
2126 let out = svc.launch(input).await.expect("launch ok");
2127 assert_eq!(out.final_ctx["s2"], "HI!");
2128
2129 let run = run_store.get(&run_id).await.expect("run present");
2130 assert_eq!(
2131 run.step_entries.len(),
2132 2,
2133 "expected one step_entry per dispatched step, got {:?}",
2134 run.step_entries
2135 );
2136 assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
2137 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
2138 assert!(run.step_entries[0].binding_digest.is_some());
2139 assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
2140 assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
2141 assert!(run.step_entries[1].binding_digest.is_some());
2142 let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2143 assert_eq!(snapshot["bound_agents"].as_array().unwrap().len(), 2);
2144 }
2145
2146 #[tokio::test]
2147 async fn run_snapshot_reuses_bound_agent_after_blueprint_mutation() {
2148 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2149 use crate::types::{RunId, TaskId};
2150
2151 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2152 let run_id = RunId::new();
2153 run_store
2154 .create(RunRecord {
2155 id: run_id.clone(),
2156 task_id: TaskId::new(),
2157 status: RunStatus::Running,
2158 step_entries: Vec::new(),
2159 degradations: Vec::new(),
2160 operator_sid: None,
2161 current: Default::default(),
2162 next_generation: 0,
2163 result_ref: None,
2164 input_json: Some("{}".to_string()),
2165 created_at: 0,
2166 updated_at: 0,
2167 })
2168 .await
2169 .unwrap();
2170 let run_ctx = RunContext::new(run_id, run_store);
2171 let mut original_agent = agent("worker", "worker");
2172 original_agent.profile = Some(crate::blueprint::AgentProfile {
2173 system_prompt: "original role".to_string(),
2174 ..Default::default()
2175 });
2176 let mut blueprint = bp(
2177 step("worker", path("$.input"), path("$.out")),
2178 vec![original_agent],
2179 );
2180
2181 let (original, _) = load_or_resolve_bound_agents(
2182 &blueprint,
2183 Some(&run_ctx),
2184 None,
2185 LegacyWorkerBindingPolicy::Allow,
2186 )
2187 .await
2188 .unwrap();
2189 blueprint.agents[0].profile.as_mut().unwrap().system_prompt = "mutated role".to_string();
2190 let (restored, _) = load_or_resolve_bound_agents(
2191 &blueprint,
2192 Some(&run_ctx),
2193 None,
2194 LegacyWorkerBindingPolicy::Allow,
2195 )
2196 .await
2197 .unwrap();
2198
2199 assert_eq!(restored[0].binding_digest, original[0].binding_digest);
2200 assert_eq!(
2201 restored[0].agent.profile.as_ref().unwrap().system_prompt,
2202 "original role"
2203 );
2204 }
2205
2206 #[tokio::test]
2207 async fn strict_migration_policy_rejects_fresh_legacy_worker_binding() {
2208 let mut legacy_agent = agent("worker", "worker");
2209 legacy_agent.profile = Some(AgentProfile {
2210 worker_binding: Some("legacy-worker".to_string()),
2211 ..Default::default()
2212 });
2213 let blueprint = bp(
2214 step("worker", path("$.input"), path("$.out")),
2215 vec![legacy_agent],
2216 );
2217
2218 let error =
2219 load_or_resolve_bound_agents(&blueprint, None, None, LegacyWorkerBindingPolicy::Reject)
2220 .await
2221 .expect_err("strict migration policy must reject fallback");
2222 assert!(error
2223 .to_string()
2224 .contains("deprecated profile.worker_binding"));
2225 }
2226
2227 #[tokio::test]
2228 async fn run_snapshot_calls_binding_provider_only_on_first_resolution() {
2229 use crate::binding::{AgentBindingProvider, BindingProviderError};
2230 use crate::blueprint::{BindOutcome, BindReceipt, BindRequest};
2231 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2232 use crate::types::{RunId, TaskId};
2233 use std::sync::atomic::{AtomicUsize, Ordering};
2234
2235 struct CountingProvider(AtomicUsize);
2236
2237 #[async_trait::async_trait]
2238 impl AgentBindingProvider for CountingProvider {
2239 async fn bind(
2240 &self,
2241 requests: &[BindRequest],
2242 ) -> Result<Vec<BindOutcome>, BindingProviderError> {
2243 self.0.fetch_add(1, Ordering::SeqCst);
2244 Ok(requests
2245 .iter()
2246 .map(|request| BindOutcome::Bound {
2247 receipt: BindReceipt {
2248 agent: request.agent.clone(),
2249 request_digest: request.request_digest.clone(),
2250 provider_id: "operator-main-ai".to_string(),
2251 provider_revision: Some("test".to_string()),
2252 resolved_model: request.requested_model.clone(),
2253 effective_tools: request.requested_tools.clone(),
2254 launch_variant: request.launch_variant.clone(),
2255 capability_snapshot_digest: None,
2256 },
2257 })
2258 .collect())
2259 }
2260 }
2261
2262 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2263 let run_id = RunId::new();
2264 run_store
2265 .create(RunRecord {
2266 id: run_id.clone(),
2267 task_id: TaskId::new(),
2268 status: RunStatus::Running,
2269 step_entries: Vec::new(),
2270 degradations: Vec::new(),
2271 operator_sid: None,
2272 current: Default::default(),
2273 next_generation: 0,
2274 result_ref: None,
2275 input_json: Some("{}".to_string()),
2276 created_at: 0,
2277 updated_at: 0,
2278 })
2279 .await
2280 .unwrap();
2281 let run_ctx = RunContext::new(run_id, run_store);
2282 let mut blueprint = bp(
2283 step("worker", path("$.input"), path("$.out")),
2284 vec![agent("worker", "worker")],
2285 );
2286 blueprint.agents[0].runner = Some(Runner::WsClaudeCode {
2287 variant: "mse-worker".to_string(),
2288 tools: vec!["Read".to_string()],
2289 });
2290 let provider = CountingProvider(AtomicUsize::new(0));
2291
2292 let (first, _) = load_or_resolve_bound_agents(
2293 &blueprint,
2294 Some(&run_ctx),
2295 Some(&provider),
2296 LegacyWorkerBindingPolicy::Allow,
2297 )
2298 .await
2299 .unwrap();
2300 let (restored, _) = load_or_resolve_bound_agents(
2301 &blueprint,
2302 Some(&run_ctx),
2303 Some(&provider),
2304 LegacyWorkerBindingPolicy::Allow,
2305 )
2306 .await
2307 .unwrap();
2308
2309 assert_eq!(provider.0.load(Ordering::SeqCst), 1);
2310 assert!(first[0].attestation.is_some());
2311 assert_eq!(restored, first);
2312 }
2313
2314 #[tokio::test]
2323 async fn fresh_resolve_on_launch_persists_launch_origin_no_degradation() {
2324 use crate::store::run::{
2325 InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, BOUND_AGENTS_ORIGIN_KEY,
2326 };
2327 use crate::types::{RunId, TaskId};
2328
2329 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2330 let run_id = RunId::new();
2331 run_store
2332 .create(RunRecord {
2333 id: run_id.clone(),
2334 task_id: TaskId::new(),
2335 status: RunStatus::Running,
2336 step_entries: Vec::new(),
2337 degradations: Vec::new(),
2338 operator_sid: None,
2339 current: Default::default(),
2340 next_generation: 0,
2341 result_ref: None,
2342 input_json: Some("{}".to_string()),
2343 created_at: 0,
2344 updated_at: 0,
2345 })
2346 .await
2347 .unwrap();
2348 let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
2350 let blueprint = bp(
2351 step("worker", path("$.input"), path("$.out")),
2352 vec![agent("worker", "worker")],
2353 );
2354
2355 load_or_resolve_bound_agents(
2356 &blueprint,
2357 Some(&run_ctx),
2358 None,
2359 LegacyWorkerBindingPolicy::Allow,
2360 )
2361 .await
2362 .expect("launch resolve ok");
2363
2364 let run = run_store.get(&run_id).await.expect("run present");
2365 let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2366 assert!(
2367 snapshot["bound_agents"].is_array(),
2368 "bound_agents must be persisted"
2369 );
2370 assert_eq!(snapshot[BOUND_AGENTS_ORIGIN_KEY], json!("launch"));
2371 assert_eq!(
2372 SnapshotOrigin::from_snapshot(&snapshot),
2373 SnapshotOrigin::Launch
2374 );
2375 assert!(
2376 run.degradations.is_empty(),
2377 "an initial-launch resolve is not a degradation"
2378 );
2379 }
2380
2381 #[tokio::test]
2386 async fn backfill_on_resume_persists_resume_origin_and_records_degradation() {
2387 use crate::store::run::{
2388 InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, BOUND_AGENTS_ORIGIN_KEY,
2389 };
2390 use crate::types::{RunId, TaskId};
2391
2392 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2393 let run_id = RunId::new();
2394 run_store
2395 .create(RunRecord {
2396 id: run_id.clone(),
2397 task_id: TaskId::new(),
2398 status: RunStatus::Running,
2399 step_entries: Vec::new(),
2400 degradations: Vec::new(),
2401 operator_sid: None,
2402 current: Default::default(),
2403 next_generation: 0,
2404 result_ref: None,
2405 input_json: Some("{}".to_string()),
2406 created_at: 0,
2407 updated_at: 0,
2408 })
2409 .await
2410 .unwrap();
2411 let run_ctx = RunContext::new(run_id.clone(), run_store.clone()).with_resume();
2412 let blueprint = bp(
2413 step("worker", path("$.input"), path("$.out")),
2414 vec![agent("worker", "worker")],
2415 );
2416
2417 load_or_resolve_bound_agents(
2418 &blueprint,
2419 Some(&run_ctx),
2420 None,
2421 LegacyWorkerBindingPolicy::Allow,
2422 )
2423 .await
2424 .expect("resume backfill ok");
2425
2426 let run = run_store.get(&run_id).await.expect("run present");
2427 let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2428 assert_eq!(snapshot[BOUND_AGENTS_ORIGIN_KEY], json!("resume_backfill"));
2429 assert_eq!(
2430 SnapshotOrigin::from_snapshot(&snapshot),
2431 SnapshotOrigin::ResumeBackfill
2432 );
2433 assert_eq!(
2434 run.degradations.len(),
2435 1,
2436 "a resume backfill must record exactly one degradation"
2437 );
2438 assert_eq!(run.degradations[0].tool, "binding");
2439 assert_eq!(run.degradations[0].fallback, "resume_backfill");
2440 }
2441
2442 fn runner_blueprint(strict_binding: bool) -> Blueprint {
2449 let mut blueprint = bp(
2450 step("worker", path("$.input"), path("$.out")),
2451 vec![agent("worker", "worker")],
2452 );
2453 blueprint.strategy.strict_binding = strict_binding;
2454 blueprint.agents[0].runner = Some(Runner::WsClaudeCode {
2455 variant: "mse-worker".to_string(),
2456 tools: vec!["Read".to_string()],
2457 });
2458 blueprint
2459 }
2460
2461 struct AlwaysUnboundProvider;
2464
2465 #[async_trait::async_trait]
2466 impl AgentBindingProvider for AlwaysUnboundProvider {
2467 async fn bind(
2468 &self,
2469 requests: &[crate::blueprint::BindRequest],
2470 ) -> Result<Vec<crate::blueprint::BindOutcome>, crate::binding::BindingProviderError>
2471 {
2472 Ok(requests
2473 .iter()
2474 .map(|request| crate::blueprint::BindOutcome::Unbound {
2475 agent: request.agent.clone(),
2476 reason: "no capability manifest submitted".to_string(),
2477 })
2478 .collect())
2479 }
2480 }
2481
2482 #[tokio::test]
2486 async fn non_strict_unbound_agent_runs_declaration_only_with_degradation() {
2487 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2488 use crate::types::{RunId, TaskId};
2489
2490 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2491 let run_id = RunId::new();
2492 run_store
2493 .create(RunRecord {
2494 id: run_id.clone(),
2495 task_id: TaskId::new(),
2496 status: RunStatus::Running,
2497 step_entries: Vec::new(),
2498 degradations: Vec::new(),
2499 operator_sid: None,
2500 current: Default::default(),
2501 next_generation: 0,
2502 result_ref: None,
2503 input_json: Some("{}".to_string()),
2504 created_at: 0,
2505 updated_at: 0,
2506 })
2507 .await
2508 .unwrap();
2509 let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
2510
2511 let (bound, _) = load_or_resolve_bound_agents(
2512 &runner_blueprint(false),
2513 Some(&run_ctx),
2514 Some(&AlwaysUnboundProvider),
2515 LegacyWorkerBindingPolicy::Allow,
2516 )
2517 .await
2518 .expect("non-strict launch must succeed even without an attestation");
2519 assert!(
2520 bound[0].attestation.is_none(),
2521 "an unattested agent must stay DeclarationOnly"
2522 );
2523
2524 let run = run_store.get(&run_id).await.expect("run present");
2525 assert_eq!(run.degradations.len(), 1, "expected one degradation entry");
2526 assert_eq!(run.degradations[0].tool, "binding");
2527 assert_eq!(run.degradations[0].fallback, "DeclarationOnly");
2528 assert!(run.degradations[0].error.contains("no capability manifest"));
2529 }
2530
2531 #[tokio::test]
2535 async fn strict_unbound_agent_fails_with_requirements_in_message() {
2536 let error = load_or_resolve_bound_agents(
2537 &runner_blueprint(true),
2538 None,
2539 Some(&AlwaysUnboundProvider),
2540 LegacyWorkerBindingPolicy::Allow,
2541 )
2542 .await
2543 .expect_err("strict + Unbound must reject the launch");
2544 match error {
2545 TaskLaunchError::PreDispatch(message) => {
2546 assert!(message.contains("worker"), "message: {message}");
2547 assert!(message.contains("mse-worker"), "message: {message}");
2548 assert!(message.contains("Read"), "message: {message}");
2549 }
2550 other => panic!("expected PreDispatch, got {other:?}"),
2551 }
2552 }
2553
2554 #[tokio::test]
2557 async fn strict_without_provider_rejects_runner_backed_launch() {
2558 let error = load_or_resolve_bound_agents(
2559 &runner_blueprint(true),
2560 None,
2561 None,
2562 LegacyWorkerBindingPolicy::Allow,
2563 )
2564 .await
2565 .expect_err("strict + no provider must reject a Runner-backed launch");
2566 match error {
2567 TaskLaunchError::PreDispatch(message) => {
2568 assert!(
2569 message.contains("strict_binding requires a binding provider"),
2570 "message: {message}"
2571 );
2572 }
2573 other => panic!("expected PreDispatch, got {other:?}"),
2574 }
2575 }
2576
2577 #[tokio::test]
2580 async fn strict_with_correct_manifest_attests_the_agent() {
2581 use crate::binding::ManifestBindingProvider;
2582 use crate::blueprint::{AgentProviderCapability, AgentProviderManifest};
2583
2584 let provider = ManifestBindingProvider::new(AgentProviderManifest {
2585 provider_id: "operator-main-ai".to_string(),
2586 provider_revision: Some("1".to_string()),
2587 capabilities: vec![AgentProviderCapability {
2588 launch_variant: Some("mse-worker".to_string()),
2589 resolved_model: None,
2590 effective_tools: vec!["Read".to_string()],
2591 capability_snapshot_digest: None,
2592 }],
2593 });
2594 let (bound, _) = load_or_resolve_bound_agents(
2595 &runner_blueprint(true),
2596 None,
2597 Some(&provider),
2598 LegacyWorkerBindingPolicy::Allow,
2599 )
2600 .await
2601 .expect("strict launch with a correct manifest must attest");
2602 assert!(
2603 bound[0].attestation.is_some(),
2604 "a correctly attested agent must carry its attestation"
2605 );
2606 }
2607
2608 #[test]
2617 fn worker_bindings_carry_request_digest_and_model() {
2618 let mut blueprint = runner_blueprint(false);
2619 blueprint.agents[0].profile = Some(AgentProfile {
2620 model: Some("claude-sonnet".to_string()),
2621 ..Default::default()
2622 });
2623 let bound = resolve_bound_agents(&blueprint).expect("resolvable Runner refs");
2624 let bindings = worker_bindings_from_bound_agents(&bound);
2625
2626 let wb = bindings.get("worker").expect("worker binding present");
2627 assert_eq!(
2628 wb.request_digest.as_ref(),
2629 Some(&bound[0].binding_digest),
2630 "the spawn frame must carry the immutable snapshot digest"
2631 );
2632 assert!(wb
2633 .request_digest
2634 .as_ref()
2635 .unwrap()
2636 .as_str()
2637 .starts_with("sha256:"));
2638 assert_eq!(wb.requested_model.as_deref(), Some("claude-sonnet"));
2639 }
2640
2641 #[test]
2644 fn worker_bindings_omit_model_when_profile_has_none() {
2645 let bound = resolve_bound_agents(&runner_blueprint(false)).expect("resolvable Runner refs");
2646 let bindings = worker_bindings_from_bound_agents(&bound);
2647 let wb = bindings.get("worker").expect("worker binding present");
2648 assert!(wb.request_digest.is_some());
2649 assert!(wb.requested_model.is_none());
2650 }
2651
2652 #[tokio::test]
2653 async fn launch_without_run_ctx_appends_no_step_entries() {
2654 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2658 Ok(WorkerResult {
2659 value: json!(inv.prompt),
2660 ok: true,
2661 stats: None,
2662 })
2663 });
2664 let svc = build_service(factory);
2665 let blueprint = bp(
2666 step("echo", path("$.input"), path("$.out")),
2667 vec![agent("echo", "echo")],
2668 );
2669 let input = launch_input(blueprint, json!({ "input": "hi" }));
2670 assert!(
2671 input.run_ctx.is_none(),
2672 "automate() defaults run_ctx to None"
2673 );
2674 let out = svc.launch(input).await.expect("launch ok");
2675 assert_eq!(out.final_ctx["out"], "hi");
2676 }
2677
2678 #[tokio::test]
2684 async fn launch_with_task_input_leaves_init_ctx_object_seed_unmutated() {
2685 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2692 Ok(WorkerResult {
2693 value: json!({ "echoed": inv.prompt }),
2694 ok: true,
2695 stats: None,
2696 })
2697 });
2698 let svc = build_service(factory);
2699 let blueprint = bp(
2700 step("echo", path("$.input"), path("$.out")),
2701 vec![agent("echo", "echo")],
2702 );
2703 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2704 input.task_input = Some(TaskInputSpec {
2705 project_root: Some("/repo".to_string()),
2706 work_dir: Some("/repo/work".to_string()),
2707 task_metadata: Some(json!({ "issue": 19 })),
2708 });
2709 let out = svc.launch(input).await.expect("launch ok");
2710 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
2711 assert!(
2712 out.final_ctx.get("project_root").is_none(),
2713 "task_input must not be folded into the flow-ir ctx seed, got {:?}",
2714 out.final_ctx
2715 );
2716 assert!(out.final_ctx.get("work_dir").is_none());
2717 assert!(out.final_ctx.get("task_metadata").is_none());
2718 }
2719
2720 #[tokio::test]
2721 async fn launch_with_task_input_none_is_a_no_op() {
2722 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2723 Ok(WorkerResult {
2724 value: json!(inv.prompt),
2725 ok: true,
2726 stats: None,
2727 })
2728 });
2729 let svc = build_service(factory);
2730 let blueprint = bp(
2731 step("echo", path("$.input"), path("$.out")),
2732 vec![agent("echo", "echo")],
2733 );
2734 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2735 assert!(input.task_input.is_none(), "automate() defaults to None");
2736 input.task_input = None;
2737 let out = svc.launch(input).await.expect("launch ok");
2738 assert_eq!(out.final_ctx["out"], "hi");
2739 }
2740
2741 #[tokio::test]
2742 async fn launch_with_task_input_all_fields_absent_is_a_no_op() {
2743 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2747 Ok(WorkerResult {
2748 value: json!(inv.prompt),
2749 ok: true,
2750 stats: None,
2751 })
2752 });
2753 let svc = build_service(factory);
2754 let blueprint = bp(
2755 step("echo", path("$.input"), path("$.out")),
2756 vec![agent("echo", "echo")],
2757 );
2758 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2759 input.task_input = Some(TaskInputSpec::default());
2760 let out = svc.launch(input).await.expect("launch ok");
2761 assert_eq!(out.final_ctx["out"], "hi");
2762 }
2763
2764 #[test]
2769 fn merge_init_ctx_bp_default_only_passes_through_when_task_is_empty_object() {
2770 let bp_default = json!({ "seeded": "from-bp" });
2771 let task = json!({});
2772 let merged = merge_init_ctx(Some(&bp_default), &task);
2773 assert_eq!(merged, json!({ "seeded": "from-bp" }));
2774 }
2775
2776 #[test]
2777 fn merge_init_ctx_task_only_passes_through_when_bp_default_is_empty_object() {
2778 let bp_default = json!({});
2779 let task = json!({ "seeded": "from-task" });
2780 let merged = merge_init_ctx(Some(&bp_default), &task);
2781 assert_eq!(merged, json!({ "seeded": "from-task" }));
2782 }
2783
2784 #[test]
2785 fn merge_init_ctx_both_objects_task_wins_on_key_collision() {
2786 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2787 let task = json!({ "a": "task", "c": "task-only" });
2788 let merged = merge_init_ctx(Some(&bp_default), &task);
2789 assert_eq!(
2790 merged,
2791 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
2792 );
2793 }
2794
2795 #[test]
2796 fn merge_init_ctx_non_object_task_fully_replaces_bp_default() {
2797 let bp_default = json!({ "seeded": "from-bp" });
2798 let task = json!("plain-string-seed");
2799 let merged = merge_init_ctx(Some(&bp_default), &task);
2800 assert_eq!(merged, json!("plain-string-seed"));
2801 }
2802
2803 #[test]
2804 fn merge_init_ctx_no_bp_default_is_a_no_op() {
2805 let task = json!({ "input": "hi" });
2806 let merged = merge_init_ctx(None, &task);
2807 assert_eq!(merged, task);
2808 }
2809
2810 #[test]
2815 fn merge_init_ctx_3layer_no_run_override_equals_bp_task_merge_only() {
2816 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2820 let task = json!({ "a": "task", "c": "task-only" });
2821 let three_layer = merge_init_ctx_3layer(Some(&bp_default), &task, None);
2822 let two_layer = merge_init_ctx(Some(&bp_default), &task);
2823 assert_eq!(three_layer, two_layer);
2824 assert_eq!(
2825 three_layer,
2826 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
2827 );
2828 }
2829
2830 #[test]
2831 fn merge_init_ctx_3layer_run_object_wins_on_key_collision_over_bp_and_task() {
2832 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2833 let task = json!({ "a": "task", "c": "task-only" });
2834 let run_override = json!({ "a": "run", "d": "run-only" });
2835 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
2836 assert_eq!(
2837 merged,
2838 json!({ "a": "run", "b": "bp-only", "c": "task-only", "d": "run-only" }),
2839 "Run wins on collision (a); BP-only (b) and Task-only (c) keys survive"
2840 );
2841 }
2842
2843 #[test]
2844 fn merge_init_ctx_3layer_run_non_object_fully_replaces_bp_task_merge() {
2845 let bp_default = json!({ "seeded": "from-bp" });
2846 let task = json!({ "seeded": "from-task" });
2847 let run_override = json!("plain-string-run-seed");
2848 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
2849 assert_eq!(merged, json!("plain-string-run-seed"));
2850 }
2851
2852 #[test]
2853 fn merge_init_ctx_3layer_no_bp_default_and_no_run_override_is_task_passthrough() {
2854 let task = json!({ "input": "hi" });
2855 let merged = merge_init_ctx_3layer(None, &task, None);
2856 assert_eq!(merged, task);
2857 }
2858
2859 #[tokio::test]
2860 async fn launch_merges_bp_default_init_ctx_into_task_init_ctx() {
2861 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2864 Ok(WorkerResult {
2865 value: json!(inv.prompt),
2866 ok: true,
2867 stats: None,
2868 })
2869 });
2870 let svc = build_service(factory);
2871 let mut blueprint = bp(
2872 step("echo", path("$.greeting"), path("$.out")),
2873 vec![agent("echo", "echo")],
2874 );
2875 blueprint.default_init_ctx = Some(json!({ "greeting": "hello from bp" }));
2876 let out = svc
2878 .launch(launch_input(blueprint, json!({})))
2879 .await
2880 .expect("launch ok");
2881 assert_eq!(out.final_ctx["out"], "hello from bp");
2882 }
2883
2884 fn agent_with_meta(name: &str, fn_id: &str, meta: AgentMeta) -> AgentDef {
2889 AgentDef {
2890 name: name.to_string(),
2891 kind: AgentKind::RustFn,
2892 spec: json!({ "fn_id": fn_id }),
2893 profile: None,
2894 meta: Some(meta),
2895 runner: None,
2896 runner_ref: None,
2897 verdict: None,
2898 lints: None,
2899 max_concurrency: None,
2900 }
2901 }
2902
2903 #[test]
2904 fn derive_agent_ctx_empty_blueprint_yields_empty_state() {
2905 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2906 let (global, per_agent) = derive_agent_ctx(&blueprint);
2907 assert_eq!(global, None);
2908 assert!(per_agent.is_empty());
2909 }
2910
2911 #[test]
2912 fn derive_agent_ctx_populated_blueprint_yields_correct_maps() {
2913 let mut blueprint = bp(
2914 step("echo", path("$.in"), path("$.out")),
2915 vec![
2916 agent_with_meta(
2917 "with-ctx",
2918 "echo",
2919 AgentMeta {
2920 ctx: Some(json!({ "org_conventions": "x" })),
2921 ..Default::default()
2922 },
2923 ),
2924 agent("no-ctx", "echo"),
2925 ],
2926 );
2927 blueprint.default_agent_ctx = Some(json!({ "seeded": "from-bp" }));
2928 let (global, per_agent) = derive_agent_ctx(&blueprint);
2929 assert_eq!(global, Some(json!({ "seeded": "from-bp" })));
2930 assert_eq!(
2931 per_agent.len(),
2932 1,
2933 "agents without AgentMeta.ctx are absent, not defaulted to null: {per_agent:?}"
2934 );
2935 assert_eq!(
2936 per_agent.get("with-ctx"),
2937 Some(&json!({ "org_conventions": "x" }))
2938 );
2939 assert!(!per_agent.contains_key("no-ctx"));
2940 }
2941
2942 #[test]
2943 fn derive_context_policies_empty_blueprint_yields_empty_state() {
2944 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2945 let (default_policy, per_agent) = derive_context_policies(&blueprint);
2946 assert_eq!(default_policy, None);
2947 assert!(per_agent.is_empty());
2948 }
2949
2950 #[test]
2951 fn derive_context_policies_populated_blueprint_yields_correct_maps() {
2952 let mut blueprint = bp(
2953 step("echo", path("$.in"), path("$.out")),
2954 vec![
2955 agent_with_meta(
2956 "with-policy",
2957 "echo",
2958 AgentMeta {
2959 context_policy: Some(ContextPolicy {
2960 include: None,
2961 exclude: vec!["work_dir".to_string()],
2962 ..Default::default()
2963 }),
2964 ..Default::default()
2965 },
2966 ),
2967 agent("no-policy", "echo"),
2968 ],
2969 );
2970 blueprint.default_context_policy = Some(ContextPolicy {
2971 include: Some(vec!["project_root".to_string()]),
2972 exclude: vec![],
2973 ..Default::default()
2974 });
2975 let (default_policy, per_agent) = derive_context_policies(&blueprint);
2976 assert_eq!(
2977 default_policy,
2978 Some(ContextPolicy {
2979 include: Some(vec!["project_root".to_string()]),
2980 exclude: vec![],
2981 ..Default::default()
2982 })
2983 );
2984 assert_eq!(per_agent.len(), 1);
2985 assert_eq!(
2986 per_agent.get("with-policy"),
2987 Some(&ContextPolicy {
2988 include: None,
2989 exclude: vec!["work_dir".to_string()],
2990 ..Default::default()
2991 })
2992 );
2993 assert!(!per_agent.contains_key("no-policy"));
2994 }
2995
2996 #[test]
3002 fn derive_step_metas_empty_blueprint_yields_empty_map() {
3003 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
3004 assert!(derive_step_metas(&blueprint).is_empty());
3005 }
3006
3007 #[test]
3008 fn derive_step_metas_populated_blueprint_yields_name_to_ctx_map() {
3009 let mut blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
3010 blueprint.metas = vec![
3011 MetaDef {
3012 name: "heavy-scan".to_string(),
3013 ctx: json!({ "work_dir": "/x" }),
3014 },
3015 MetaDef {
3016 name: "light-scan".to_string(),
3017 ctx: json!({ "work_dir": "/y" }),
3018 },
3019 ];
3020 let metas = derive_step_metas(&blueprint);
3021 assert_eq!(metas.len(), 2);
3022 assert_eq!(metas.get("heavy-scan"), Some(&json!({ "work_dir": "/x" })));
3023 assert_eq!(metas.get("light-scan"), Some(&json!({ "work_dir": "/y" })));
3024 }
3025
3026 #[test]
3027 fn derive_agent_ctx_meta_ref_resolves_as_base_under_inline_ctx() {
3028 let mut blueprint = bp(
3029 step("echo", path("$.in"), path("$.out")),
3030 vec![agent_with_meta(
3031 "with-meta-ref",
3032 "echo",
3033 AgentMeta {
3034 ctx: Some(json!({ "work_dir": "/inline-wins" })),
3035 meta_ref: Some("shared".to_string()),
3036 ..Default::default()
3037 },
3038 )],
3039 );
3040 blueprint.metas = vec![MetaDef {
3041 name: "shared".to_string(),
3042 ctx: json!({ "work_dir": "/base", "extra": "from-pool" }),
3043 }];
3044 let (_, per_agent) = derive_agent_ctx(&blueprint);
3045 assert_eq!(
3046 per_agent.get("with-meta-ref"),
3047 Some(&json!({ "work_dir": "/inline-wins", "extra": "from-pool" })),
3048 "inline ctx must win the collided key while pool-only keys survive the merge"
3049 );
3050 }
3051
3052 #[test]
3053 fn derive_agent_ctx_meta_ref_alone_uses_pool_ctx_verbatim() {
3054 let mut blueprint = bp(
3055 step("echo", path("$.in"), path("$.out")),
3056 vec![agent_with_meta(
3057 "with-meta-ref-only",
3058 "echo",
3059 AgentMeta {
3060 meta_ref: Some("shared".to_string()),
3061 ..Default::default()
3062 },
3063 )],
3064 );
3065 blueprint.metas = vec![MetaDef {
3066 name: "shared".to_string(),
3067 ctx: json!({ "work_dir": "/base" }),
3068 }];
3069 let (_, per_agent) = derive_agent_ctx(&blueprint);
3070 assert_eq!(
3071 per_agent.get("with-meta-ref-only"),
3072 Some(&json!({ "work_dir": "/base" }))
3073 );
3074 }
3075
3076 #[test]
3077 fn derive_agent_ctx_unresolved_meta_ref_never_panics_and_falls_back_to_inline() {
3078 let blueprint = bp(
3079 step("echo", path("$.in"), path("$.out")),
3080 vec![agent_with_meta(
3081 "with-unresolved-meta-ref",
3082 "echo",
3083 AgentMeta {
3084 ctx: Some(json!({ "work_dir": "/inline-only" })),
3085 meta_ref: Some("missing".to_string()),
3086 ..Default::default()
3087 },
3088 )],
3089 );
3090 let (_, per_agent) = derive_agent_ctx(&blueprint);
3092 assert_eq!(
3093 per_agent.get("with-unresolved-meta-ref"),
3094 Some(&json!({ "work_dir": "/inline-only" })),
3095 "an unresolved meta_ref must never panic; the agent's own inline ctx still applies"
3096 );
3097 }
3098
3099 #[test]
3115 fn resolve_runner_legacy_fallback_matches_derive_worker_bindings_semantics() {
3116 fn legacy_agent(name: &str, variant: &str, tools: Vec<&str>) -> AgentDef {
3117 AgentDef {
3118 name: name.to_string(),
3119 kind: AgentKind::Operator,
3120 spec: json!({}),
3121 profile: Some(AgentProfile {
3122 worker_binding: Some(variant.to_string()),
3123 tools: tools.into_iter().map(str::to_string).collect(),
3124 ..Default::default()
3125 }),
3126 meta: None,
3127 runner: None,
3128 runner_ref: None,
3129 verdict: None,
3130 lints: None,
3131 max_concurrency: None,
3132 }
3133 }
3134
3135 let blueprint = bp(
3136 step("planner", path("$.in"), path("$.out")),
3137 vec![
3138 legacy_agent("planner", "planning-worker", vec!["Read", "Grep"]),
3139 legacy_agent("coder", "code-worker", vec![]),
3140 agent("no-binding", "echo"),
3141 ],
3142 );
3143
3144 let derived = derive_worker_bindings(&blueprint);
3145
3146 for agent_def in &blueprint.agents {
3147 let resolved = resolve_runner(&blueprint, agent_def).expect("no unresolved refs");
3148 match derived.get(&agent_def.name) {
3149 Some(binding) => {
3150 assert_eq!(
3151 resolved,
3152 Some(Runner::WsClaudeCode {
3153 variant: binding.variant.clone(),
3154 tools: binding.tools.clone(),
3155 }),
3156 "resolve_runner must synthesize the same WsClaudeCode Runner \
3157 derive_worker_bindings produces for agent '{}'",
3158 agent_def.name
3159 );
3160 }
3161 None => {
3162 assert_eq!(
3163 resolved, None,
3164 "agent '{}' has no derive_worker_bindings entry, so resolve_runner \
3165 must resolve to None too (no other tier declared)",
3166 agent_def.name
3167 );
3168 }
3169 }
3170 }
3171 }
3172
3173 #[test]
3174 fn ws_operator_runner_projects_into_the_existing_spawn_binding() {
3175 let mut blueprint = bp(
3176 step("reviewer", path("$.in"), path("$.out")),
3177 vec![agent("reviewer", "echo")],
3178 );
3179 blueprint.agents[0].runner = Some(Runner::WsOperator {
3180 variant: "mse-reviewer".to_string(),
3181 tools: vec!["Read".to_string(), "Grep".to_string()],
3182 });
3183
3184 let derived = derive_worker_bindings(&blueprint);
3185 let binding = derived
3186 .get("reviewer")
3187 .expect("ws_operator must feed the canonical spawn binding path");
3188 assert_eq!(binding.variant, "mse-reviewer");
3189 assert_eq!(binding.tools, ["Read", "Grep"]);
3190 }
3191
3192 fn counting_echo_service() -> (TaskLaunchService, Arc<std::sync::atomic::AtomicUsize>) {
3201 use std::sync::atomic::{AtomicUsize, Ordering};
3202 let calls = Arc::new(AtomicUsize::new(0));
3203 let counter = calls.clone();
3204 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", move |inv| {
3205 let counter = counter.clone();
3206 async move {
3207 counter.fetch_add(1, Ordering::SeqCst);
3208 Ok(WorkerResult {
3209 value: json!({ "echoed": inv.prompt }),
3210 ok: true,
3211 stats: None,
3212 })
3213 }
3214 });
3215 (build_service(factory), calls)
3216 }
3217
3218 async fn seed_legacy_run(run_store: &Arc<dyn crate::store::run::RunStore>) -> crate::RunId {
3219 use crate::store::run::{RunRecord, RunStatus};
3220 use crate::types::TaskId;
3221 let run_id = crate::RunId::new();
3222 run_store
3223 .create(RunRecord {
3224 id: run_id.clone(),
3225 task_id: TaskId::new(),
3226 status: RunStatus::Running,
3227 step_entries: Vec::new(),
3228 degradations: Vec::new(),
3229 operator_sid: None,
3230 current: Default::default(),
3231 next_generation: 0,
3232 result_ref: None,
3233 input_json: Some("{}".to_string()),
3236 created_at: 0,
3237 updated_at: 0,
3238 })
3239 .await
3240 .expect("seed legacy RunRecord");
3241 run_id
3242 }
3243
3244 #[tokio::test]
3250 async fn backfilled_run_replays_legacy_keys_stably_across_two_resumes() {
3251 use crate::store::replay::{InMemoryReplayStore, ReplayCursor, ReplayStore};
3252 use crate::store::run::{InMemoryRunStore, RunContext, RunStore};
3253 use std::sync::atomic::Ordering;
3254 use std::sync::Mutex;
3255
3256 let (svc, echo_calls) = counting_echo_service();
3257 let blueprint = bp(
3258 step("echo", path("$.input"), path("$.out")),
3259 vec![agent("echo", "echo")],
3260 );
3261 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3262 let replay_store: Arc<dyn ReplayStore> = Arc::new(InMemoryReplayStore::new());
3263 let run_id = seed_legacy_run(&run_store).await;
3264
3265 let rc1 = RunContext::new(run_id.clone(), run_store.clone())
3269 .with_replay_store(replay_store.clone())
3270 .with_resume();
3271 let mut input1 = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3272 input1.run_ctx = Some(rc1);
3273 let out1 = svc.launch(input1).await.expect("phase-1 resume launch ok");
3274 assert_eq!(out1.final_ctx["out"]["echoed"], "hi");
3275 assert_eq!(
3276 echo_calls.load(Ordering::SeqCst),
3277 1,
3278 "phase 1 dispatches the worker once (nothing to replay yet)"
3279 );
3280 let entries = replay_store
3281 .list_by_run(&run_id)
3282 .await
3283 .expect("list replay rows");
3284 assert_eq!(
3285 entries.len(),
3286 1,
3287 "phase 1 must log exactly one legacy-hashed replay row"
3288 );
3289 let run = run_store.get(&run_id).await.expect("run present");
3290 let snap: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3291 assert_eq!(
3292 SnapshotOrigin::from_snapshot(&snap),
3293 SnapshotOrigin::ResumeBackfill,
3294 "phase 1 must pin the snapshot as resume_backfill"
3295 );
3296
3297 let cursor = ReplayCursor::from_entries(entries);
3302 let rc2 = RunContext::new(run_id.clone(), run_store.clone())
3303 .with_replay_store(replay_store.clone())
3304 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
3305 .with_resume();
3306 let mut input2 = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3307 input2.run_ctx = Some(rc2);
3308 let out2 = svc.launch(input2).await.expect("phase-2 resume launch ok");
3309 assert_eq!(out2.final_ctx["out"]["echoed"], "hi");
3310 assert_eq!(
3311 echo_calls.load(Ordering::SeqCst),
3312 1,
3313 "phase 2 must REPLAY the legacy-hashed row — the worker must not run again"
3314 );
3315 let run2 = run_store.get(&run_id).await.expect("run present");
3316 let snap2: Value = serde_json::from_str(run2.input_json.as_deref().unwrap()).unwrap();
3317 assert_eq!(
3318 SnapshotOrigin::from_snapshot(&snap2),
3319 SnapshotOrigin::ResumeBackfill,
3320 "origin must stay resume_backfill across resumes (replay key stability)"
3321 );
3322 }
3323
3324 #[tokio::test]
3329 async fn launch_origin_run_uses_digest_keys_and_misses_legacy_replay_row() {
3330 use crate::store::replay::{InMemoryReplayStore, ReplayCursor, ReplayStore};
3331 use crate::store::run::{InMemoryRunStore, RunContext, RunStore};
3332 use std::sync::atomic::Ordering;
3333 use std::sync::Mutex;
3334
3335 let (svc, echo_calls) = counting_echo_service();
3336 let blueprint = bp(
3337 step("echo", path("$.input"), path("$.out")),
3338 vec![agent("echo", "echo")],
3339 );
3340 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3341 let replay_store: Arc<dyn ReplayStore> = Arc::new(InMemoryReplayStore::new());
3342
3343 let backfill_run = seed_legacy_run(&run_store).await;
3345 let rc_bf = RunContext::new(backfill_run.clone(), run_store.clone())
3346 .with_replay_store(replay_store.clone())
3347 .with_resume();
3348 let mut input_bf = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3349 input_bf.run_ctx = Some(rc_bf);
3350 svc.launch(input_bf).await.expect("backfill launch ok");
3351 assert_eq!(echo_calls.load(Ordering::SeqCst), 1);
3352 let legacy_entries = replay_store
3353 .list_by_run(&backfill_run)
3354 .await
3355 .expect("list legacy rows");
3356 assert_eq!(legacy_entries.len(), 1);
3357
3358 let launch_run = seed_legacy_run(&run_store).await;
3362 let cursor = ReplayCursor::from_entries(legacy_entries);
3363 let rc_launch = RunContext::new(launch_run.clone(), run_store.clone())
3364 .with_replay_store(replay_store.clone())
3365 .with_replay_cursor(Arc::new(Mutex::new(cursor)));
3366 let mut input_launch = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3367 input_launch.run_ctx = Some(rc_launch);
3368 svc.launch(input_launch)
3369 .await
3370 .expect("launch-origin launch ok");
3371 assert_eq!(
3372 echo_calls.load(Ordering::SeqCst),
3373 2,
3374 "a launch-origin Run keys replay by binding digest, so the \
3375 legacy-hashed row must MISS and the worker must run"
3376 );
3377 let run = run_store.get(&launch_run).await.expect("run present");
3378 let snap: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3379 assert_eq!(SnapshotOrigin::from_snapshot(&snap), SnapshotOrigin::Launch);
3380 }
3381
3382 #[test]
3390 fn task_launch_error_flow_eval_struct_variant_display_preserves_prefix() {
3391 let err = TaskLaunchError::FlowEval {
3392 message: "dispatcher error at ref foo".to_string(),
3393 failed_step: Some("foo".to_string()),
3394 verdict_value: Some(json!({"verdict": "BLOCKED"})),
3395 partial_ctx: Some(json!({"steps": {}})),
3396 };
3397 assert_eq!(err.to_string(), "flow eval: dispatcher error at ref foo");
3398
3399 let err_bare = TaskLaunchError::FlowEval {
3403 message: "unresolved extern".to_string(),
3404 failed_step: None,
3405 verdict_value: None,
3406 partial_ctx: None,
3407 };
3408 assert_eq!(err_bare.to_string(), "flow eval: unresolved extern");
3409 }
3410
3411 #[tokio::test]
3416 async fn task_launch_flow_eval_error_carries_failed_step_and_verdict_value() {
3417 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
3418 use crate::types::{RunId, TaskId};
3419
3420 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |_inv| async move {
3421 Ok(WorkerResult {
3422 value: json!({ "verdict": "BLOCKED", "reason": "not applicable" }),
3423 ok: false,
3424 stats: None,
3425 })
3426 });
3427 let svc = build_service(factory);
3428 let blueprint = bp(
3429 step("gate", path("$.input"), path("$.out")),
3430 vec![agent("gate", "gate")],
3431 );
3432
3433 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3434 let run_id = RunId::new();
3435 run_store
3436 .create(RunRecord {
3437 id: run_id.clone(),
3438 task_id: TaskId::new(),
3439 status: RunStatus::Running,
3440 step_entries: Vec::new(),
3441 degradations: Vec::new(),
3442 operator_sid: None,
3443 current: Default::default(),
3444 next_generation: 0,
3445 result_ref: None,
3446 input_json: Some("{}".to_string()),
3447 created_at: 0,
3448 updated_at: 0,
3449 })
3450 .await
3451 .expect("seed RunRecord");
3452
3453 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
3454 input.run_ctx = Some(RunContext::new(run_id, run_store));
3455
3456 let err = svc.launch(input).await.expect_err("expected FlowEval");
3457 match err {
3458 TaskLaunchError::FlowEval {
3459 message,
3460 failed_step,
3461 verdict_value,
3462 partial_ctx,
3463 } => {
3464 assert!(
3465 message.contains("blocked"),
3466 "expected message to mention blocked, got: {message}"
3467 );
3468 assert_eq!(
3469 failed_step,
3470 Some("gate".to_string()),
3471 "failed_step should be the Blueprint step ref, not the opaque StepId"
3472 );
3473 let vv = verdict_value.expect("verdict_value must be Some for Blocked");
3474 assert_eq!(vv["verdict"], "BLOCKED");
3475 assert_eq!(vv["reason"], "not applicable");
3476 assert!(
3480 partial_ctx.is_some(),
3481 "partial_ctx must be Some when a RunContext was supplied"
3482 );
3483 }
3484 other => panic!("expected FlowEval, got {other:?}"),
3485 }
3486 }
3487
3488 #[tokio::test]
3494 async fn task_launch_flow_eval_error_partial_ctx_reconstructs_from_run_store() {
3495 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
3496 use crate::types::{RunId, TaskId};
3497
3498 let factory = RustFnInProcessSpawnerFactory::new()
3499 .register_fn("upper", |inv| async move {
3500 Ok(WorkerResult {
3501 value: json!(inv.prompt.to_uppercase()),
3502 ok: true,
3503 stats: None,
3504 })
3505 })
3506 .register_fn("gate", |_inv| async move {
3507 Ok(WorkerResult {
3508 value: json!({ "verdict": "BLOCKED" }),
3509 ok: false,
3510 stats: None,
3511 })
3512 })
3513 .register_fn("never", |inv| async move {
3514 Ok(WorkerResult {
3515 value: json!(inv.prompt),
3516 ok: true,
3517 stats: None,
3518 })
3519 });
3520 let svc = build_service(factory);
3521 let flow = FlowNode::Seq {
3522 children: vec![
3523 step("upper", path("$.in"), path("$.s1")),
3524 step("gate", path("$.s1"), path("$.s2")),
3525 step("never", path("$.s2"), path("$.s3")),
3526 ],
3527 };
3528 let blueprint = bp(
3529 flow,
3530 vec![
3531 agent("upper", "upper"),
3532 agent("gate", "gate"),
3533 agent("never", "never"),
3534 ],
3535 );
3536
3537 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3538 let run_id = RunId::new();
3539 run_store
3540 .create(RunRecord {
3541 id: run_id.clone(),
3542 task_id: TaskId::new(),
3543 status: RunStatus::Running,
3544 step_entries: Vec::new(),
3545 degradations: Vec::new(),
3546 operator_sid: None,
3547 current: Default::default(),
3548 next_generation: 0,
3549 result_ref: None,
3550 input_json: Some("{}".to_string()),
3551 created_at: 0,
3552 updated_at: 0,
3553 })
3554 .await
3555 .expect("seed RunRecord");
3556
3557 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
3558 input.run_ctx = Some(RunContext::new(run_id.clone(), run_store.clone()));
3559
3560 let err = svc.launch(input).await.expect_err("expected FlowEval");
3561 let partial_ctx = match err {
3562 TaskLaunchError::FlowEval { partial_ctx, .. } => {
3563 partial_ctx.expect("partial_ctx must be Some")
3564 }
3565 other => panic!("expected FlowEval, got {other:?}"),
3566 };
3567 let steps = partial_ctx
3568 .get("steps")
3569 .and_then(|v| v.as_object())
3570 .expect("partial_ctx.steps object");
3571 assert_eq!(
3575 steps.len(),
3576 2,
3577 "expected 2 step_entries (upper passed + gate blocked), got: {steps:?}"
3578 );
3579 let mut status_by_ref: HashMap<String, String> = HashMap::new();
3580 for (_step_id, entry) in steps {
3581 let step_ref = entry
3582 .get("step_ref")
3583 .and_then(|v| v.as_str())
3584 .expect("step_ref present")
3585 .to_string();
3586 let status = entry
3587 .get("status")
3588 .and_then(|v| v.as_str())
3589 .expect("status present")
3590 .to_string();
3591 status_by_ref.insert(step_ref, status);
3592 }
3593 assert_eq!(
3594 status_by_ref.get("upper").map(String::as_str),
3595 Some("passed")
3596 );
3597 assert_eq!(
3598 status_by_ref.get("gate").map(String::as_str),
3599 Some("blocked")
3600 );
3601 assert!(
3602 !status_by_ref.contains_key("never"),
3603 "step 'never' must not appear — flow-ir stops dispatching after Blocked abort"
3604 );
3605 }
3606
3607 #[tokio::test]
3613 async fn task_launch_flow_eval_error_without_run_ctx_has_none_fields() {
3614 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |_inv| async move {
3615 Ok(WorkerResult {
3616 value: json!({ "verdict": "BLOCKED" }),
3617 ok: false,
3618 stats: None,
3619 })
3620 });
3621 let svc = build_service(factory);
3622 let blueprint = bp(
3623 step("gate", path("$.input"), path("$.out")),
3624 vec![agent("gate", "gate")],
3625 );
3626 let err = svc
3627 .launch(launch_input(blueprint, json!({ "input": "hi" })))
3628 .await
3629 .expect_err("expected FlowEval");
3630 match err {
3631 TaskLaunchError::FlowEval {
3632 failed_step,
3633 verdict_value,
3634 partial_ctx,
3635 ..
3636 } => {
3637 assert_eq!(
3638 failed_step, None,
3639 "failed_step must be None without run_ctx"
3640 );
3641 assert_eq!(
3642 verdict_value, None,
3643 "verdict_value must be None without run_ctx"
3644 );
3645 assert_eq!(
3646 partial_ctx, None,
3647 "partial_ctx must be None without run_ctx"
3648 );
3649 }
3650 other => panic!("expected FlowEval, got {other:?}"),
3651 }
3652 }
3653}