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_sid: Option<String>,
699 pub operator_kind_overrides: HashMap<String, OperatorKind>,
704 pub init_ctx: Value,
709 pub task_input: Option<TaskInputSpec>,
716 pub run_ctx: Option<RunContext>,
723 pub check_policy: Option<CheckPolicy>,
746}
747
748impl TaskLaunchInput {
749 pub fn automate(
759 blueprint: Blueprint,
760 operator_id: impl Into<String>,
761 role: Role,
762 ttl: Duration,
763 init_ctx: Value,
764 ) -> Self {
765 Self {
766 blueprint,
767 operator_id: operator_id.into(),
768 role,
769 ttl,
770 operator_kind: None,
771 bridge_id: None,
772 hook_id: None,
773 operator_sid: None,
774 operator_kind_overrides: HashMap::new(),
775 init_ctx,
776 task_input: None,
777 run_ctx: None,
778 check_policy: None,
779 }
780 }
781}
782
783#[derive(Debug, Clone)]
785pub struct TaskLaunchOutput {
786 pub token: CapToken,
788 pub final_ctx: Value,
792}
793
794pub struct TaskLaunchService {
798 engine: Engine,
799 compiler: Compiler,
800 externs: Arc<dyn Externs + Send + Sync>,
805 binding_provider: Option<Arc<dyn AgentBindingProvider>>,
808 legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
811}
812
813impl TaskLaunchService {
814 pub fn new(engine: Engine, compiler: Compiler) -> Self {
816 Self {
817 engine,
818 compiler,
819 externs: Arc::new(NoExterns),
820 binding_provider: None,
821 legacy_worker_binding_policy: LegacyWorkerBindingPolicy::default(),
822 }
823 }
824
825 pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
829 self.externs = externs;
830 self
831 }
832
833 pub fn with_binding_provider(mut self, provider: Arc<dyn AgentBindingProvider>) -> Self {
837 self.binding_provider = Some(provider);
838 self
839 }
840
841 pub fn with_legacy_worker_binding_policy(mut self, policy: LegacyWorkerBindingPolicy) -> Self {
843 self.legacy_worker_binding_policy = policy;
844 self
845 }
846
847 pub fn engine(&self) -> &Engine {
849 &self.engine
850 }
851
852 pub fn compiler(&self) -> &Compiler {
854 &self.compiler
855 }
856
857 pub async fn launch(
869 &self,
870 mut input: TaskLaunchInput,
871 ) -> Result<TaskLaunchOutput, TaskLaunchError> {
872 let pinned_binding_provider: Option<Arc<dyn AgentBindingProvider>> = input
886 .operator_sid
887 .as_deref()
888 .and_then(|pin| self.binding_provider.as_ref()?.pinned_to_session(pin));
889 let binding_provider = pinned_binding_provider
890 .as_deref()
891 .or(self.binding_provider.as_deref());
892 let (bound_agents, snapshot_origin) = load_or_resolve_bound_agents(
893 &input.blueprint,
894 input.run_ctx.as_ref(),
895 binding_provider,
896 self.legacy_worker_binding_policy,
897 )
898 .await?;
899 let binding_digests: HashMap<String, crate::blueprint::BindingDigest> = bound_agents
900 .iter()
901 .map(|bound| (bound.agent.name.clone(), bound.binding_digest.clone()))
902 .collect();
903 if let Some(run_ctx) = input.run_ctx.take() {
904 input.run_ctx = Some(match snapshot_origin {
915 SnapshotOrigin::Launch => run_ctx.with_binding_digests(binding_digests.clone()),
916 SnapshotOrigin::ResumeBackfill => run_ctx,
917 });
918 }
919 input.blueprint = materialize_bound_blueprint(&input.blueprint, &bound_agents);
920 let compiled = self
921 .compiler
922 .compile_bound(&input.blueprint, &bound_agents)?;
923 self.engine
932 .register_verdict_contracts(compiled.router.verdict_contracts.clone());
933 let spawner = linker::link(
934 compiled.router.clone(),
935 &input.blueprint.spawner_hints.layers,
936 &self.engine,
937 );
938 let (agent_ctx_global, agent_ctx_per_agent) = derive_agent_ctx(&input.blueprint);
954 let (context_policy_default, context_policy_per_agent) =
955 derive_context_policies(&input.blueprint);
956 let spawner = SpawnerStack::new(spawner)
957 .layer(AgentContextMiddleware::new(
958 agent_ctx_global,
959 agent_ctx_per_agent,
960 context_policy_default,
961 context_policy_per_agent,
962 ))
963 .build();
964 let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
971 SpawnerStack::new(spawner)
972 .layer(ProjectNameAliasMiddleware::new(alias))
973 .build()
974 } else {
975 spawner
976 };
977 let worker_bindings = worker_bindings_from_bound_agents(&bound_agents);
981 let spawner = if worker_bindings.is_empty() {
982 spawner
983 } else {
984 SpawnerStack::new(spawner)
985 .layer(WorkerBindingMiddleware::new(worker_bindings))
986 .build()
987 };
988 let audit_defs = derive_audits(&input.blueprint);
998 let spawner = if audit_defs.is_empty() {
999 spawner
1000 } else {
1001 SpawnerStack::new(spawner)
1002 .layer(AfterRunAuditMiddleware::new(
1003 audit_defs,
1004 compiled.router.clone(),
1005 ))
1006 .build()
1007 };
1008
1009 let spawner = match input.task_input.as_ref().and_then(|spec| {
1016 TaskInputMiddleware::new_from_fields(
1017 spec.project_root.clone(),
1018 spec.work_dir.clone(),
1019 spec.task_metadata.clone(),
1020 )
1021 }) {
1022 Some(task_input) => SpawnerStack::new(spawner).layer(task_input).build(),
1023 None => spawner,
1024 };
1025
1026 let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
1031 let bp_global_kind = input
1032 .blueprint
1033 .default_operator_kind
1034 .map(OperatorKind::from);
1035
1036 let token = self
1055 .engine
1056 .attach_with_ids(
1057 input.operator_id,
1058 input.role,
1059 input.ttl,
1060 input.operator_kind,
1061 input.bridge_id,
1062 input.hook_id,
1063 input.operator_sid,
1064 input.operator_kind_overrides,
1065 bp_agent_kinds,
1066 bp_global_kind,
1067 )
1068 .await?;
1069 let resolved_check_policy = input.check_policy.or(input.blueprint.check_policy);
1080 let effective_check_policy =
1095 resolved_check_policy.unwrap_or(self.engine.cfg().check_policy);
1096 if effective_check_policy == CheckPolicy::Strict {
1097 let roots_missing = input
1098 .task_input
1099 .as_ref()
1100 .map(|t| t.project_root.is_none() && t.work_dir.is_none())
1101 .unwrap_or(true);
1102 if roots_missing {
1103 return Err(TaskLaunchError::PreDispatch(
1104 "check_policy=strict requires project_root or work_dir, but the launch \
1105 supplied neither"
1106 .to_string(),
1107 ));
1108 }
1109 }
1110 let dispatcher =
1111 EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
1112 let dispatcher = dispatcher.with_check_policy(resolved_check_policy);
1113 let map_err_run_ctx = input.run_ctx.clone();
1121 let dispatcher = match input.run_ctx {
1122 Some(run_ctx) => dispatcher.with_run(run_ctx),
1123 None => dispatcher,
1124 };
1125 let dispatcher = dispatcher.with_step_metas(derive_step_metas(&input.blueprint));
1129 let dispatcher = dispatcher.with_binding_digests(binding_digests);
1130 let dispatcher = dispatcher.with_step_naming(compiled.step_naming.clone());
1136 let dispatcher =
1143 dispatcher.with_projection_placement(compiled.projection_placement.clone());
1144 let merged_init_ctx =
1150 merge_init_ctx(input.blueprint.default_init_ctx.as_ref(), &input.init_ctx);
1151 let eval_result = mlua_flow_ir::eval_async_externs(
1152 &input.blueprint.flow,
1153 merged_init_ctx,
1154 &dispatcher,
1155 &*self.externs,
1156 )
1157 .await;
1158 let final_ctx = match eval_result {
1159 Ok(v) => v,
1160 Err(e) => {
1161 let (failed_step, verdict_value) = match &map_err_run_ctx {
1181 Some(rc) => {
1182 let slot = rc.last_failure.lock().ok().and_then(|g| g.clone());
1183 match slot {
1184 Some(lf) => (
1185 lf.step_ref.clone().or_else(|| Some(lf.step_id.to_string())),
1186 Some(lf.verdict_value.clone()),
1187 ),
1188 None => (None, None),
1189 }
1190 }
1191 None => (None, None),
1192 };
1193 let partial_ctx = match &map_err_run_ctx {
1194 Some(rc) => Some(rc.snapshot_partial_ctx().await),
1195 None => None,
1196 };
1197 return Err(TaskLaunchError::FlowEval {
1198 message: e.to_string(),
1199 failed_step,
1200 verdict_value,
1201 partial_ctx,
1202 });
1203 }
1204 };
1205 Ok(TaskLaunchOutput { token, final_ctx })
1206 }
1207}
1208
1209#[cfg(test)]
1214mod tests {
1215 use super::*;
1216 use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
1217 use crate::blueprint::{
1218 current_schema_version, resolve_runner, AgentDef, AgentKind, AgentMeta, AgentProfile,
1219 BlueprintMetadata, CompilerHints, CompilerStrategy, MetaDef, Runner,
1220 };
1221 use crate::core::config::EngineCfg;
1222 use crate::worker::adapter::{WorkerError, WorkerResult};
1223 use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
1224 use serde_json::json;
1225 use std::sync::Arc;
1226
1227 fn path(s: &str) -> Expr {
1228 Expr::Path {
1229 at: s.parse().expect("literal test path"),
1230 }
1231 }
1232 fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
1233 FlowNode::Step {
1234 ref_: ref_.to_string(),
1235 in_,
1236 out,
1237 }
1238 }
1239
1240 fn agent(name: &str, fn_id: &str) -> AgentDef {
1241 AgentDef {
1242 name: name.to_string(),
1243 kind: AgentKind::RustFn,
1244 spec: json!({ "fn_id": fn_id }),
1245 profile: None,
1246 meta: Some(AgentMeta::default()),
1247 runner: None,
1248 runner_ref: None,
1249 verdict: None,
1250 lints: None,
1251 }
1252 }
1253
1254 fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
1255 let engine = Engine::new(EngineCfg::default());
1256 let mut reg = SpawnerRegistry::new();
1257 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1258 let compiler = Compiler::new(reg);
1259 TaskLaunchService::new(engine, compiler)
1260 }
1261
1262 fn build_service_with_cfg(
1266 factory: RustFnInProcessSpawnerFactory,
1267 cfg: EngineCfg,
1268 ) -> TaskLaunchService {
1269 let engine = Engine::new(cfg);
1270 let mut reg = SpawnerRegistry::new();
1271 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1272 let compiler = Compiler::new(reg);
1273 TaskLaunchService::new(engine, compiler)
1274 }
1275
1276 fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
1277 Blueprint {
1278 schema_version: current_schema_version(),
1279 id: "ut".into(),
1280 flow,
1281 agents,
1282 operators: vec![],
1283 metas: vec![],
1284 hints: CompilerHints::default(),
1285 strategy: CompilerStrategy::default(),
1286 metadata: BlueprintMetadata::default(),
1287 spawner_hints: Default::default(),
1288 default_agent_kind: AgentKind::Operator,
1289 default_operator_kind: None,
1290 default_init_ctx: None,
1291 default_agent_ctx: None,
1292 default_context_policy: None,
1293 projection_placement: None,
1294 audits: vec![],
1295 degradation_policy: None,
1296 runners: vec![],
1297 default_runner: None,
1298 subprocesses: vec![],
1299 check_policy: None,
1300 blueprint_ref_includes: Vec::new(),
1301 }
1302 }
1303
1304 fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
1305 TaskLaunchInput::automate(
1306 blueprint,
1307 "ut-op",
1308 Role::Operator,
1309 Duration::from_secs(30),
1310 init_ctx,
1311 )
1312 }
1313
1314 #[test]
1320 fn derive_audits_empty_by_default() {
1321 let blueprint = bp(
1322 step("echo", path("$.input"), path("$.out")),
1323 vec![agent("echo", "echo")],
1324 );
1325 assert!(
1326 derive_audits(&blueprint).is_empty(),
1327 "audits_absent_no_layer: an undeclared audits Vec must stay empty"
1328 );
1329 }
1330
1331 #[test]
1332 fn derive_audits_returns_blueprint_audits_verbatim() {
1333 let mut blueprint = bp(
1334 step("echo", path("$.input"), path("$.out")),
1335 vec![agent("echo", "echo")],
1336 );
1337 blueprint.audits = vec![crate::blueprint::AuditDef {
1338 agent: "auditor".to_string(),
1339 steps: None,
1340 mode: crate::blueprint::AuditMode::Async,
1341 }];
1342 let got = derive_audits(&blueprint);
1343 assert_eq!(got.len(), 1);
1344 assert_eq!(got[0].agent, "auditor");
1345 }
1346
1347 #[tokio::test]
1348 async fn launch_appends_audit_artifact_when_audits_declared() {
1349 use crate::blueprint::{AuditDef, AuditMode};
1350
1351 let factory = RustFnInProcessSpawnerFactory::new()
1352 .register_fn("echo", |inv| async move {
1353 Ok(WorkerResult {
1354 value: json!({ "echoed": inv.prompt }),
1355 ok: true,
1356 stats: None,
1357 })
1358 })
1359 .register_fn("audit-fn", |_inv| async move {
1360 Ok(WorkerResult {
1361 value: json!({ "finding": "clean" }),
1362 ok: true,
1363 stats: None,
1364 })
1365 });
1366 let svc = build_service(factory);
1367 let mut blueprint = bp(
1368 step("echo", path("$.input"), path("$.out")),
1369 vec![agent("echo", "echo"), agent("auditor", "audit-fn")],
1370 );
1371 blueprint.audits = vec![AuditDef {
1372 agent: "auditor".to_string(),
1373 steps: None,
1374 mode: AuditMode::Sync,
1375 }];
1376 let out = svc
1377 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1378 .await
1379 .expect("launch ok — audits must never alter the audited step's outcome");
1380 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1381
1382 let audited_task_id = svc
1383 .engine()
1384 .with_state("test.find_audited_task", |s| {
1385 s.tasks
1386 .iter()
1387 .find(|(_, t)| t.spec.agent == "echo")
1388 .map(|(id, _)| id.clone())
1389 })
1390 .await
1391 .expect("with_state")
1392 .expect("the echo task must exist");
1393 let tail = svc.engine().output_tail(&audited_task_id, 1).await;
1394 let found = tail.iter().any(|ev| {
1395 matches!(
1396 ev,
1397 crate::worker::output::OutputEvent::Artifact { name, .. } if name == "audit:echo"
1398 )
1399 });
1400 assert!(
1401 found,
1402 "launch() must wire AfterRunAuditMiddleware end-to-end when Blueprint.audits is declared"
1403 );
1404 }
1405
1406 #[tokio::test]
1407 async fn launch_single_step_writes_out_path() {
1408 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1409 Ok(WorkerResult {
1410 value: json!({ "echoed": inv.prompt }),
1411 ok: true,
1412 stats: None,
1413 })
1414 });
1415 let svc = build_service(factory);
1416 let blueprint = bp(
1417 step("echo", path("$.input"), path("$.out")),
1418 vec![agent("echo", "echo")],
1419 );
1420 let out = svc
1421 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1422 .await
1423 .expect("launch ok");
1424 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1425 }
1426
1427 async fn dispatched_check_policy(
1448 launch_policy: Option<CheckPolicy>,
1449 bp_policy: Option<CheckPolicy>,
1450 ) -> Option<CheckPolicy> {
1451 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1452 Ok(WorkerResult {
1453 value: json!({ "echoed": inv.prompt }),
1454 ok: true,
1455 stats: None,
1456 })
1457 });
1458 let svc = build_service(factory);
1459 let mut blueprint = bp(
1460 step("echo", path("$.input"), path("$.out")),
1461 vec![agent("echo", "echo")],
1462 );
1463 blueprint.check_policy = bp_policy;
1464 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1465 input.check_policy = launch_policy;
1466 input.task_input = Some(TaskInputSpec {
1467 project_root: None,
1468 work_dir: Some("/dispatched-check-policy-test-root".to_string()),
1469 task_metadata: None,
1470 });
1471 let _ = svc.launch(input).await;
1472 svc.engine()
1473 .with_state("test.read_dispatched_check_policy", |s| {
1474 s.tasks
1475 .values()
1476 .find(|t| t.spec.agent == "echo")
1477 .and_then(|t| t.spec.check_policy)
1478 })
1479 .await
1480 .expect("with_state")
1481 }
1482
1483 #[tokio::test]
1486 async fn cascade_launch_tier_wins_over_blueprint_tier() {
1487 assert_eq!(
1488 dispatched_check_policy(Some(CheckPolicy::Silent), Some(CheckPolicy::Strict)).await,
1489 Some(CheckPolicy::Silent),
1490 );
1491 }
1492
1493 #[tokio::test]
1496 async fn cascade_blueprint_tier_used_when_launch_absent() {
1497 assert_eq!(
1498 dispatched_check_policy(None, Some(CheckPolicy::Strict)).await,
1499 Some(CheckPolicy::Strict),
1500 );
1501 }
1502
1503 #[tokio::test]
1506 async fn cascade_launch_tier_alone_when_blueprint_absent() {
1507 assert_eq!(
1508 dispatched_check_policy(Some(CheckPolicy::Strict), None).await,
1509 Some(CheckPolicy::Strict),
1510 );
1511 }
1512
1513 #[tokio::test]
1518 async fn cascade_both_none_preserves_server_fallback() {
1519 assert_eq!(dispatched_check_policy(None, None).await, None);
1520 }
1521
1522 #[tokio::test]
1538 async fn strict_blueprint_without_roots_is_rejected_pre_dispatch() {
1539 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1540 Ok(WorkerResult {
1541 value: json!({ "echoed": inv.prompt }),
1542 ok: true,
1543 stats: None,
1544 })
1545 });
1546 let svc = build_service(factory);
1547 let mut blueprint = bp(
1548 step("echo", path("$.input"), path("$.out")),
1549 vec![agent("echo", "echo")],
1550 );
1551 blueprint.check_policy = Some(CheckPolicy::Strict);
1552 let err = svc
1554 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1555 .await
1556 .expect_err("strict check_policy + no roots must be rejected before dispatch");
1557 match err {
1558 TaskLaunchError::PreDispatch(message) => {
1559 assert!(
1560 message.contains("strict"),
1561 "message must identify the strict-requires-roots condition: {message}"
1562 );
1563 }
1564 other => panic!("expected TaskLaunchError::PreDispatch, got {other:?}"),
1565 }
1566
1567 let dispatched = svc
1571 .engine()
1572 .with_state("test.no_echo_task_dispatched", |s| {
1573 s.tasks.values().any(|t| t.spec.agent == "echo")
1574 })
1575 .await
1576 .expect("with_state");
1577 assert!(
1578 !dispatched,
1579 "the pre-dispatch guard must reject before any step is dispatched"
1580 );
1581 }
1582
1583 #[tokio::test]
1595 async fn launch_without_any_check_policy_completes_fail_open() {
1596 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1597 Ok(WorkerResult {
1598 value: json!({ "echoed": inv.prompt }),
1599 ok: true,
1600 stats: None,
1601 })
1602 });
1603 let svc = build_service(factory);
1604 let blueprint = bp(
1605 step("echo", path("$.input"), path("$.out")),
1606 vec![agent("echo", "echo")],
1607 );
1608 assert_eq!(blueprint.check_policy, None, "BP tier must be unset");
1609 let out = svc
1610 .launch(launch_input(blueprint, json!({ "input": "hi" })))
1611 .await
1612 .expect("warn-mode fail-open must let the launch complete");
1613 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1614 }
1615
1616 #[tokio::test]
1637 async fn strict_blueprint_with_launch_warn_override_bypasses_pre_dispatch_guard() {
1638 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1639 Ok(WorkerResult {
1640 value: json!({ "echoed": inv.prompt }),
1641 ok: true,
1642 stats: None,
1643 })
1644 });
1645 let svc = build_service(factory);
1646 let mut blueprint = bp(
1647 step("echo", path("$.input"), path("$.out")),
1648 vec![agent("echo", "echo")],
1649 );
1650 blueprint.check_policy = Some(CheckPolicy::Strict);
1651 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1652 input.check_policy = Some(CheckPolicy::Warn);
1653 assert!(input.task_input.is_none(), "no roots supplied at all");
1654 let out = svc
1655 .launch(input)
1656 .await
1657 .expect("launch-tier warn override must bypass the pre-dispatch guard");
1658 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1659 }
1660
1661 #[tokio::test]
1667 async fn server_tier_strict_alone_triggers_pre_dispatch_guard() {
1668 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1669 Ok(WorkerResult {
1670 value: json!({ "echoed": inv.prompt }),
1671 ok: true,
1672 stats: None,
1673 })
1674 });
1675 let svc = build_service_with_cfg(
1676 factory,
1677 EngineCfg {
1678 check_policy: CheckPolicy::Strict,
1679 ..EngineCfg::default()
1680 },
1681 );
1682 let blueprint = bp(
1683 step("echo", path("$.input"), path("$.out")),
1684 vec![agent("echo", "echo")],
1685 );
1686 assert_eq!(blueprint.check_policy, None, "BP tier must be unset");
1687 let input = launch_input(blueprint, json!({ "input": "hi" }));
1688 assert!(input.check_policy.is_none(), "launch tier must be unset");
1689 assert!(input.task_input.is_none(), "no roots supplied");
1690 let err = svc.launch(input).await.expect_err(
1691 "server-tier Strict alone (BP/launch tiers both unset) must trigger the guard",
1692 );
1693 match err {
1694 TaskLaunchError::PreDispatch(message) => {
1695 assert!(
1696 message.contains("strict"),
1697 "expected the strict-requires-roots message, got: {message}"
1698 );
1699 }
1700 other => panic!("expected TaskLaunchError::PreDispatch, got {other:?}"),
1701 }
1702 }
1703
1704 #[tokio::test]
1710 async fn pre_dispatch_guard_rejects_when_task_input_present_but_roots_both_none() {
1711 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1712 Ok(WorkerResult {
1713 value: json!({ "echoed": inv.prompt }),
1714 ok: true,
1715 stats: None,
1716 })
1717 });
1718 let svc = build_service(factory);
1719 let mut blueprint = bp(
1720 step("echo", path("$.input"), path("$.out")),
1721 vec![agent("echo", "echo")],
1722 );
1723 blueprint.check_policy = Some(CheckPolicy::Strict);
1724 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1725 input.task_input = Some(TaskInputSpec {
1726 project_root: None,
1727 work_dir: None,
1728 task_metadata: Some(json!({ "unrelated": true })),
1729 });
1730 let err = svc
1731 .launch(input)
1732 .await
1733 .expect_err("Some(TaskInputSpec) with both roots None must still be roots_missing");
1734 assert!(
1735 matches!(err, TaskLaunchError::PreDispatch(_)),
1736 "expected TaskLaunchError::PreDispatch, got {err:?}"
1737 );
1738 }
1739
1740 #[tokio::test]
1745 async fn pre_dispatch_guard_passes_when_work_dir_present_and_project_root_absent() {
1746 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1747 Ok(WorkerResult {
1748 value: json!({ "echoed": inv.prompt }),
1749 ok: true,
1750 stats: None,
1751 })
1752 });
1753 let svc = build_service(factory);
1754 let mut blueprint = bp(
1755 step("echo", path("$.input"), path("$.out")),
1756 vec![agent("echo", "echo")],
1757 );
1758 blueprint.check_policy = Some(CheckPolicy::Strict);
1759 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1760 input.task_input = Some(TaskInputSpec {
1761 project_root: None,
1762 work_dir: Some("/repo/work".to_string()),
1763 task_metadata: None,
1764 });
1765 let out = svc
1766 .launch(input)
1767 .await
1768 .expect("work_dir alone must satisfy the guard's roots_missing check");
1769 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1770 }
1771
1772 #[tokio::test]
1773 async fn launch_three_step_seq_threads_ctx_forward() {
1774 let factory = RustFnInProcessSpawnerFactory::new()
1775 .register_fn("upper", |inv| async move {
1776 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1777 Ok(WorkerResult {
1778 value: json!(s.to_uppercase()),
1779 ok: true,
1780 stats: None,
1781 })
1782 })
1783 .register_fn("suffix", |inv| async move {
1784 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1785 Ok(WorkerResult {
1786 value: json!(format!("{s}!")),
1787 ok: true,
1788 stats: None,
1789 })
1790 })
1791 .register_fn("wrap", |inv| async move {
1792 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1793 Ok(WorkerResult {
1794 value: json!(format!("[{s}]")),
1795 ok: true,
1796 stats: None,
1797 })
1798 });
1799 let svc = build_service(factory);
1800 let flow = FlowNode::Seq {
1801 children: vec![
1802 step("upper", path("$.in"), path("$.s1")),
1803 step("suffix", path("$.s1"), path("$.s2")),
1804 step("wrap", path("$.s2"), path("$.s3")),
1805 ],
1806 };
1807 let blueprint = bp(
1808 flow,
1809 vec![
1810 agent("upper", "upper"),
1811 agent("suffix", "suffix"),
1812 agent("wrap", "wrap"),
1813 ],
1814 );
1815 let out = svc
1816 .launch(launch_input(blueprint, json!({ "in": "hello" })))
1817 .await
1818 .expect("launch ok");
1819 assert_eq!(out.final_ctx["s1"], "HELLO");
1820 assert_eq!(out.final_ctx["s2"], "HELLO!");
1821 assert_eq!(out.final_ctx["s3"], "[HELLO!]");
1822 }
1823
1824 #[tokio::test]
1825 async fn launch_fanout_join_all_parallel_completes() {
1826 use std::sync::atomic::{AtomicU32, Ordering};
1827 let counter = Arc::new(AtomicU32::new(0));
1828 let max_seen = Arc::new(AtomicU32::new(0));
1829 let counter_clone = counter.clone();
1830 let max_clone = max_seen.clone();
1831
1832 let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
1835 let counter = counter_clone.clone();
1836 let max_seen = max_clone.clone();
1837 async move {
1838 let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
1839 let mut prev = max_seen.load(Ordering::SeqCst);
1840 while now > prev {
1841 match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
1842 Ok(_) => break,
1843 Err(p) => prev = p,
1844 }
1845 }
1846 tokio::time::sleep(Duration::from_millis(50)).await;
1847 counter.fetch_sub(1, Ordering::SeqCst);
1848 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1849 Ok(WorkerResult {
1850 value: json!(format!("did:{s}")),
1851 ok: true,
1852 stats: None,
1853 })
1854 }
1855 });
1856 let svc = build_service(factory);
1857 let flow = FlowNode::Fanout {
1858 items: path("$.items"),
1859 bind: path("$.item"),
1860 body: Box::new(step("para", path("$.item"), path("$.r"))),
1861 join: JoinMode::All,
1862 out: path("$.results"),
1863 };
1864 let blueprint = bp(flow, vec![agent("para", "para")]);
1865 let out = svc
1866 .launch(launch_input(
1867 blueprint,
1868 json!({ "items": ["a", "b", "c", "d"] }),
1869 ))
1870 .await
1871 .expect("launch ok");
1872 let results = out.final_ctx["results"].as_array().expect("array");
1873 assert_eq!(results.len(), 4);
1874 for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
1875 assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
1876 }
1877 let max = max_seen.load(Ordering::SeqCst);
1878 assert!(
1879 max >= 2,
1880 "expected parallel execution (max inflight >= 2), got {max}"
1881 );
1882 }
1883
1884 #[tokio::test]
1885 async fn launch_propagates_worker_error_as_flow_eval_err() {
1886 let factory = RustFnInProcessSpawnerFactory::new()
1887 .register_fn("ok", |inv| async move {
1888 Ok(WorkerResult {
1889 value: json!(inv.prompt),
1890 ok: true,
1891 stats: None,
1892 })
1893 })
1894 .register_fn("boom", |_inv| async move {
1895 Err(WorkerError::Failed("intentional boom".into()))
1896 });
1897 let svc = build_service(factory);
1898 let flow = FlowNode::Seq {
1899 children: vec![
1900 step("ok", path("$.input"), path("$.s1")),
1901 step("boom", path("$.s1"), path("$.s2")),
1902 step("ok", path("$.s2"), path("$.s3")),
1903 ],
1904 };
1905 let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
1906 let err = svc
1907 .launch(launch_input(blueprint, json!({ "input": "x" })))
1908 .await
1909 .expect_err("expected fail");
1910 match err {
1911 TaskLaunchError::FlowEval { message: msg, .. } => {
1912 assert!(
1913 msg.contains("boom") || msg.contains("intentional"),
1914 "expected error to mention worker failure, got: {msg}"
1915 );
1916 }
1917 other => panic!("expected FlowEval error, got {other:?}"),
1918 }
1919 }
1920
1921 #[tokio::test]
1922 async fn launch_resolves_call_extern_via_registered_externs() {
1923 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1924 Ok(WorkerResult {
1925 value: json!({ "echoed": inv.prompt }),
1926 ok: true,
1927 stats: None,
1928 })
1929 });
1930 let mut externs = mlua_flow_ir::ExternMap::new();
1931 externs.register("fmt.greet", |args: &[Value]| {
1932 let name = args[0].as_str().unwrap_or("?");
1933 Ok(json!(format!("hello, {name}")))
1934 });
1935 let svc = build_service(factory).with_externs(Arc::new(externs));
1936 let flow = step(
1937 "echo",
1938 Expr::CallExtern {
1939 ref_: "fmt.greet".into(),
1940 args: vec![path("$.who")],
1941 },
1942 path("$.out"),
1943 );
1944 let blueprint = bp(flow, vec![agent("echo", "echo")]);
1945 let out = svc
1946 .launch(launch_input(blueprint, json!({ "who": "swarm" })))
1947 .await
1948 .expect("launch ok");
1949 assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
1950 }
1951
1952 #[tokio::test]
1953 async fn launch_call_extern_without_registry_fails_as_flow_eval() {
1954 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1955 Ok(WorkerResult {
1956 value: json!(inv.prompt),
1957 ok: true,
1958 stats: None,
1959 })
1960 });
1961 let svc = build_service(factory); let flow = step(
1963 "echo",
1964 Expr::CallExtern {
1965 ref_: "fmt.greet".into(),
1966 args: vec![],
1967 },
1968 path("$.out"),
1969 );
1970 let blueprint = bp(flow, vec![agent("echo", "echo")]);
1971 let err = svc
1972 .launch(launch_input(blueprint, json!({})))
1973 .await
1974 .expect_err("expected fail");
1975 match err {
1976 TaskLaunchError::FlowEval { message: msg, .. } => {
1977 assert!(msg.contains("extern"), "expected extern error, got: {msg}");
1978 }
1979 other => panic!("expected FlowEval error, got {other:?}"),
1980 }
1981 }
1982
1983 #[tokio::test]
2003 async fn launch_registers_the_blueprints_verdict_contracts_into_the_engine() {
2004 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |inv| async move {
2005 Ok(WorkerResult {
2006 value: json!(inv.prompt),
2007 ok: true,
2008 stats: None,
2009 })
2010 });
2011 let svc = build_service(factory);
2012 let mut gate_agent = agent("gate", "gate");
2013 gate_agent.verdict = Some(mlua_swarm_schema::VerdictContract {
2014 channel: mlua_swarm_schema::VerdictChannel::Body,
2015 values: vec!["PASS".to_string(), "BLOCKED".to_string()],
2016 });
2017 let flow = step("gate", path("$.input"), path("$.out"));
2018 let blueprint = bp(flow, vec![gate_agent]);
2019
2020 let out = svc
2021 .launch(launch_input(blueprint, json!({ "input": "PASS" })))
2022 .await
2023 .expect("launch ok");
2024 assert_eq!(out.final_ctx["out"], json!("PASS"));
2025
2026 let task_id = svc
2031 .engine()
2032 .with_state("test.find_dispatched_task_id", |s| {
2033 s.tasks.keys().next().cloned()
2034 })
2035 .await
2036 .expect("with_state")
2037 .expect("launch must have dispatched exactly one Step (one TaskState)");
2038
2039 let contract = svc
2040 .engine()
2041 .verdict_contract_for_task(&task_id)
2042 .await
2043 .expect(
2044 "TaskLaunchService::launch must have merged this Blueprint's compiled \
2045 verdict_contracts into the engine's runtime registry \
2046 (Engine::register_verdict_contracts, called right after \
2047 compiler.compile succeeds) — verdict_contract_for_task resolving None \
2048 here means that production wiring regressed",
2049 );
2050 assert_eq!(contract.channel, mlua_swarm_schema::VerdictChannel::Body);
2051 assert_eq!(
2052 contract.values,
2053 vec!["PASS".to_string(), "BLOCKED".to_string()]
2054 );
2055 }
2056
2057 #[tokio::test]
2062 async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
2063 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2064 use crate::types::{RunId, TaskId};
2065
2066 let factory = RustFnInProcessSpawnerFactory::new()
2067 .register_fn("upper", |inv| async move {
2068 Ok(WorkerResult {
2069 value: json!(inv.prompt.to_uppercase()),
2070 ok: true,
2071 stats: None,
2072 })
2073 })
2074 .register_fn("suffix", |inv| async move {
2075 let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
2076 Ok(WorkerResult {
2077 value: json!(format!("{s}!")),
2078 ok: true,
2079 stats: None,
2080 })
2081 });
2082 let svc = build_service(factory);
2083 let flow = FlowNode::Seq {
2084 children: vec![
2085 step("upper", path("$.in"), path("$.s1")),
2086 step("suffix", path("$.s1"), path("$.s2")),
2087 ],
2088 };
2089 let blueprint = bp(
2090 flow,
2091 vec![agent("upper", "upper"), agent("suffix", "suffix")],
2092 );
2093
2094 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2095 let run_id = RunId::new();
2096 run_store
2097 .create(RunRecord {
2098 id: run_id.clone(),
2099 task_id: TaskId::new(),
2100 status: RunStatus::Running,
2101 step_entries: Vec::new(),
2102 degradations: Vec::new(),
2103 operator_sid: None,
2104 current: Default::default(),
2105 next_generation: 0,
2106 result_ref: None,
2107 input_json: Some("{}".to_string()),
2108 created_at: 0,
2109 updated_at: 0,
2110 })
2111 .await
2112 .expect("seed RunRecord");
2113
2114 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
2115 input.run_ctx = Some(RunContext::new(run_id.clone(), run_store.clone()));
2116
2117 let out = svc.launch(input).await.expect("launch ok");
2118 assert_eq!(out.final_ctx["s2"], "HI!");
2119
2120 let run = run_store.get(&run_id).await.expect("run present");
2121 assert_eq!(
2122 run.step_entries.len(),
2123 2,
2124 "expected one step_entry per dispatched step, got {:?}",
2125 run.step_entries
2126 );
2127 assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
2128 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
2129 assert!(run.step_entries[0].binding_digest.is_some());
2130 assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
2131 assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
2132 assert!(run.step_entries[1].binding_digest.is_some());
2133 let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2134 assert_eq!(snapshot["bound_agents"].as_array().unwrap().len(), 2);
2135 }
2136
2137 #[tokio::test]
2138 async fn run_snapshot_reuses_bound_agent_after_blueprint_mutation() {
2139 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2140 use crate::types::{RunId, TaskId};
2141
2142 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2143 let run_id = RunId::new();
2144 run_store
2145 .create(RunRecord {
2146 id: run_id.clone(),
2147 task_id: TaskId::new(),
2148 status: RunStatus::Running,
2149 step_entries: Vec::new(),
2150 degradations: Vec::new(),
2151 operator_sid: None,
2152 current: Default::default(),
2153 next_generation: 0,
2154 result_ref: None,
2155 input_json: Some("{}".to_string()),
2156 created_at: 0,
2157 updated_at: 0,
2158 })
2159 .await
2160 .unwrap();
2161 let run_ctx = RunContext::new(run_id, run_store);
2162 let mut original_agent = agent("worker", "worker");
2163 original_agent.profile = Some(crate::blueprint::AgentProfile {
2164 system_prompt: "original role".to_string(),
2165 ..Default::default()
2166 });
2167 let mut blueprint = bp(
2168 step("worker", path("$.input"), path("$.out")),
2169 vec![original_agent],
2170 );
2171
2172 let (original, _) = load_or_resolve_bound_agents(
2173 &blueprint,
2174 Some(&run_ctx),
2175 None,
2176 LegacyWorkerBindingPolicy::Allow,
2177 )
2178 .await
2179 .unwrap();
2180 blueprint.agents[0].profile.as_mut().unwrap().system_prompt = "mutated role".to_string();
2181 let (restored, _) = load_or_resolve_bound_agents(
2182 &blueprint,
2183 Some(&run_ctx),
2184 None,
2185 LegacyWorkerBindingPolicy::Allow,
2186 )
2187 .await
2188 .unwrap();
2189
2190 assert_eq!(restored[0].binding_digest, original[0].binding_digest);
2191 assert_eq!(
2192 restored[0].agent.profile.as_ref().unwrap().system_prompt,
2193 "original role"
2194 );
2195 }
2196
2197 #[tokio::test]
2198 async fn strict_migration_policy_rejects_fresh_legacy_worker_binding() {
2199 let mut legacy_agent = agent("worker", "worker");
2200 legacy_agent.profile = Some(AgentProfile {
2201 worker_binding: Some("legacy-worker".to_string()),
2202 ..Default::default()
2203 });
2204 let blueprint = bp(
2205 step("worker", path("$.input"), path("$.out")),
2206 vec![legacy_agent],
2207 );
2208
2209 let error =
2210 load_or_resolve_bound_agents(&blueprint, None, None, LegacyWorkerBindingPolicy::Reject)
2211 .await
2212 .expect_err("strict migration policy must reject fallback");
2213 assert!(error
2214 .to_string()
2215 .contains("deprecated profile.worker_binding"));
2216 }
2217
2218 #[tokio::test]
2219 async fn run_snapshot_calls_binding_provider_only_on_first_resolution() {
2220 use crate::binding::{AgentBindingProvider, BindingProviderError};
2221 use crate::blueprint::{BindOutcome, BindReceipt, BindRequest};
2222 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2223 use crate::types::{RunId, TaskId};
2224 use std::sync::atomic::{AtomicUsize, Ordering};
2225
2226 struct CountingProvider(AtomicUsize);
2227
2228 #[async_trait::async_trait]
2229 impl AgentBindingProvider for CountingProvider {
2230 async fn bind(
2231 &self,
2232 requests: &[BindRequest],
2233 ) -> Result<Vec<BindOutcome>, BindingProviderError> {
2234 self.0.fetch_add(1, Ordering::SeqCst);
2235 Ok(requests
2236 .iter()
2237 .map(|request| BindOutcome::Bound {
2238 receipt: BindReceipt {
2239 agent: request.agent.clone(),
2240 request_digest: request.request_digest.clone(),
2241 provider_id: "operator-main-ai".to_string(),
2242 provider_revision: Some("test".to_string()),
2243 resolved_model: request.requested_model.clone(),
2244 effective_tools: request.requested_tools.clone(),
2245 launch_variant: request.launch_variant.clone(),
2246 capability_snapshot_digest: None,
2247 },
2248 })
2249 .collect())
2250 }
2251 }
2252
2253 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2254 let run_id = RunId::new();
2255 run_store
2256 .create(RunRecord {
2257 id: run_id.clone(),
2258 task_id: TaskId::new(),
2259 status: RunStatus::Running,
2260 step_entries: Vec::new(),
2261 degradations: Vec::new(),
2262 operator_sid: None,
2263 current: Default::default(),
2264 next_generation: 0,
2265 result_ref: None,
2266 input_json: Some("{}".to_string()),
2267 created_at: 0,
2268 updated_at: 0,
2269 })
2270 .await
2271 .unwrap();
2272 let run_ctx = RunContext::new(run_id, run_store);
2273 let mut blueprint = bp(
2274 step("worker", path("$.input"), path("$.out")),
2275 vec![agent("worker", "worker")],
2276 );
2277 blueprint.agents[0].runner = Some(Runner::WsClaudeCode {
2278 variant: "mse-worker".to_string(),
2279 tools: vec!["Read".to_string()],
2280 });
2281 let provider = CountingProvider(AtomicUsize::new(0));
2282
2283 let (first, _) = load_or_resolve_bound_agents(
2284 &blueprint,
2285 Some(&run_ctx),
2286 Some(&provider),
2287 LegacyWorkerBindingPolicy::Allow,
2288 )
2289 .await
2290 .unwrap();
2291 let (restored, _) = load_or_resolve_bound_agents(
2292 &blueprint,
2293 Some(&run_ctx),
2294 Some(&provider),
2295 LegacyWorkerBindingPolicy::Allow,
2296 )
2297 .await
2298 .unwrap();
2299
2300 assert_eq!(provider.0.load(Ordering::SeqCst), 1);
2301 assert!(first[0].attestation.is_some());
2302 assert_eq!(restored, first);
2303 }
2304
2305 #[tokio::test]
2314 async fn fresh_resolve_on_launch_persists_launch_origin_no_degradation() {
2315 use crate::store::run::{
2316 InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, BOUND_AGENTS_ORIGIN_KEY,
2317 };
2318 use crate::types::{RunId, TaskId};
2319
2320 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2321 let run_id = RunId::new();
2322 run_store
2323 .create(RunRecord {
2324 id: run_id.clone(),
2325 task_id: TaskId::new(),
2326 status: RunStatus::Running,
2327 step_entries: Vec::new(),
2328 degradations: Vec::new(),
2329 operator_sid: None,
2330 current: Default::default(),
2331 next_generation: 0,
2332 result_ref: None,
2333 input_json: Some("{}".to_string()),
2334 created_at: 0,
2335 updated_at: 0,
2336 })
2337 .await
2338 .unwrap();
2339 let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
2341 let blueprint = bp(
2342 step("worker", path("$.input"), path("$.out")),
2343 vec![agent("worker", "worker")],
2344 );
2345
2346 load_or_resolve_bound_agents(
2347 &blueprint,
2348 Some(&run_ctx),
2349 None,
2350 LegacyWorkerBindingPolicy::Allow,
2351 )
2352 .await
2353 .expect("launch resolve ok");
2354
2355 let run = run_store.get(&run_id).await.expect("run present");
2356 let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2357 assert!(
2358 snapshot["bound_agents"].is_array(),
2359 "bound_agents must be persisted"
2360 );
2361 assert_eq!(snapshot[BOUND_AGENTS_ORIGIN_KEY], json!("launch"));
2362 assert_eq!(
2363 SnapshotOrigin::from_snapshot(&snapshot),
2364 SnapshotOrigin::Launch
2365 );
2366 assert!(
2367 run.degradations.is_empty(),
2368 "an initial-launch resolve is not a degradation"
2369 );
2370 }
2371
2372 #[tokio::test]
2377 async fn backfill_on_resume_persists_resume_origin_and_records_degradation() {
2378 use crate::store::run::{
2379 InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, BOUND_AGENTS_ORIGIN_KEY,
2380 };
2381 use crate::types::{RunId, TaskId};
2382
2383 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2384 let run_id = RunId::new();
2385 run_store
2386 .create(RunRecord {
2387 id: run_id.clone(),
2388 task_id: TaskId::new(),
2389 status: RunStatus::Running,
2390 step_entries: Vec::new(),
2391 degradations: Vec::new(),
2392 operator_sid: None,
2393 current: Default::default(),
2394 next_generation: 0,
2395 result_ref: None,
2396 input_json: Some("{}".to_string()),
2397 created_at: 0,
2398 updated_at: 0,
2399 })
2400 .await
2401 .unwrap();
2402 let run_ctx = RunContext::new(run_id.clone(), run_store.clone()).with_resume();
2403 let blueprint = bp(
2404 step("worker", path("$.input"), path("$.out")),
2405 vec![agent("worker", "worker")],
2406 );
2407
2408 load_or_resolve_bound_agents(
2409 &blueprint,
2410 Some(&run_ctx),
2411 None,
2412 LegacyWorkerBindingPolicy::Allow,
2413 )
2414 .await
2415 .expect("resume backfill ok");
2416
2417 let run = run_store.get(&run_id).await.expect("run present");
2418 let snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
2419 assert_eq!(snapshot[BOUND_AGENTS_ORIGIN_KEY], json!("resume_backfill"));
2420 assert_eq!(
2421 SnapshotOrigin::from_snapshot(&snapshot),
2422 SnapshotOrigin::ResumeBackfill
2423 );
2424 assert_eq!(
2425 run.degradations.len(),
2426 1,
2427 "a resume backfill must record exactly one degradation"
2428 );
2429 assert_eq!(run.degradations[0].tool, "binding");
2430 assert_eq!(run.degradations[0].fallback, "resume_backfill");
2431 }
2432
2433 fn runner_blueprint(strict_binding: bool) -> Blueprint {
2440 let mut blueprint = bp(
2441 step("worker", path("$.input"), path("$.out")),
2442 vec![agent("worker", "worker")],
2443 );
2444 blueprint.strategy.strict_binding = strict_binding;
2445 blueprint.agents[0].runner = Some(Runner::WsClaudeCode {
2446 variant: "mse-worker".to_string(),
2447 tools: vec!["Read".to_string()],
2448 });
2449 blueprint
2450 }
2451
2452 struct AlwaysUnboundProvider;
2455
2456 #[async_trait::async_trait]
2457 impl AgentBindingProvider for AlwaysUnboundProvider {
2458 async fn bind(
2459 &self,
2460 requests: &[crate::blueprint::BindRequest],
2461 ) -> Result<Vec<crate::blueprint::BindOutcome>, crate::binding::BindingProviderError>
2462 {
2463 Ok(requests
2464 .iter()
2465 .map(|request| crate::blueprint::BindOutcome::Unbound {
2466 agent: request.agent.clone(),
2467 reason: "no capability manifest submitted".to_string(),
2468 })
2469 .collect())
2470 }
2471 }
2472
2473 #[tokio::test]
2477 async fn non_strict_unbound_agent_runs_declaration_only_with_degradation() {
2478 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
2479 use crate::types::{RunId, TaskId};
2480
2481 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2482 let run_id = RunId::new();
2483 run_store
2484 .create(RunRecord {
2485 id: run_id.clone(),
2486 task_id: TaskId::new(),
2487 status: RunStatus::Running,
2488 step_entries: Vec::new(),
2489 degradations: Vec::new(),
2490 operator_sid: None,
2491 current: Default::default(),
2492 next_generation: 0,
2493 result_ref: None,
2494 input_json: Some("{}".to_string()),
2495 created_at: 0,
2496 updated_at: 0,
2497 })
2498 .await
2499 .unwrap();
2500 let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
2501
2502 let (bound, _) = load_or_resolve_bound_agents(
2503 &runner_blueprint(false),
2504 Some(&run_ctx),
2505 Some(&AlwaysUnboundProvider),
2506 LegacyWorkerBindingPolicy::Allow,
2507 )
2508 .await
2509 .expect("non-strict launch must succeed even without an attestation");
2510 assert!(
2511 bound[0].attestation.is_none(),
2512 "an unattested agent must stay DeclarationOnly"
2513 );
2514
2515 let run = run_store.get(&run_id).await.expect("run present");
2516 assert_eq!(run.degradations.len(), 1, "expected one degradation entry");
2517 assert_eq!(run.degradations[0].tool, "binding");
2518 assert_eq!(run.degradations[0].fallback, "DeclarationOnly");
2519 assert!(run.degradations[0].error.contains("no capability manifest"));
2520 }
2521
2522 #[tokio::test]
2526 async fn strict_unbound_agent_fails_with_requirements_in_message() {
2527 let error = load_or_resolve_bound_agents(
2528 &runner_blueprint(true),
2529 None,
2530 Some(&AlwaysUnboundProvider),
2531 LegacyWorkerBindingPolicy::Allow,
2532 )
2533 .await
2534 .expect_err("strict + Unbound must reject the launch");
2535 match error {
2536 TaskLaunchError::PreDispatch(message) => {
2537 assert!(message.contains("worker"), "message: {message}");
2538 assert!(message.contains("mse-worker"), "message: {message}");
2539 assert!(message.contains("Read"), "message: {message}");
2540 }
2541 other => panic!("expected PreDispatch, got {other:?}"),
2542 }
2543 }
2544
2545 #[tokio::test]
2548 async fn strict_without_provider_rejects_runner_backed_launch() {
2549 let error = load_or_resolve_bound_agents(
2550 &runner_blueprint(true),
2551 None,
2552 None,
2553 LegacyWorkerBindingPolicy::Allow,
2554 )
2555 .await
2556 .expect_err("strict + no provider must reject a Runner-backed launch");
2557 match error {
2558 TaskLaunchError::PreDispatch(message) => {
2559 assert!(
2560 message.contains("strict_binding requires a binding provider"),
2561 "message: {message}"
2562 );
2563 }
2564 other => panic!("expected PreDispatch, got {other:?}"),
2565 }
2566 }
2567
2568 #[tokio::test]
2571 async fn strict_with_correct_manifest_attests_the_agent() {
2572 use crate::binding::ManifestBindingProvider;
2573 use crate::blueprint::{AgentProviderCapability, AgentProviderManifest};
2574
2575 let provider = ManifestBindingProvider::new(AgentProviderManifest {
2576 provider_id: "operator-main-ai".to_string(),
2577 provider_revision: Some("1".to_string()),
2578 capabilities: vec![AgentProviderCapability {
2579 launch_variant: Some("mse-worker".to_string()),
2580 resolved_model: None,
2581 effective_tools: vec!["Read".to_string()],
2582 capability_snapshot_digest: None,
2583 }],
2584 });
2585 let (bound, _) = load_or_resolve_bound_agents(
2586 &runner_blueprint(true),
2587 None,
2588 Some(&provider),
2589 LegacyWorkerBindingPolicy::Allow,
2590 )
2591 .await
2592 .expect("strict launch with a correct manifest must attest");
2593 assert!(
2594 bound[0].attestation.is_some(),
2595 "a correctly attested agent must carry its attestation"
2596 );
2597 }
2598
2599 #[test]
2608 fn worker_bindings_carry_request_digest_and_model() {
2609 let mut blueprint = runner_blueprint(false);
2610 blueprint.agents[0].profile = Some(AgentProfile {
2611 model: Some("claude-sonnet".to_string()),
2612 ..Default::default()
2613 });
2614 let bound = resolve_bound_agents(&blueprint).expect("resolvable Runner refs");
2615 let bindings = worker_bindings_from_bound_agents(&bound);
2616
2617 let wb = bindings.get("worker").expect("worker binding present");
2618 assert_eq!(
2619 wb.request_digest.as_ref(),
2620 Some(&bound[0].binding_digest),
2621 "the spawn frame must carry the immutable snapshot digest"
2622 );
2623 assert!(wb
2624 .request_digest
2625 .as_ref()
2626 .unwrap()
2627 .as_str()
2628 .starts_with("sha256:"));
2629 assert_eq!(wb.requested_model.as_deref(), Some("claude-sonnet"));
2630 }
2631
2632 #[test]
2635 fn worker_bindings_omit_model_when_profile_has_none() {
2636 let bound = resolve_bound_agents(&runner_blueprint(false)).expect("resolvable Runner refs");
2637 let bindings = worker_bindings_from_bound_agents(&bound);
2638 let wb = bindings.get("worker").expect("worker binding present");
2639 assert!(wb.request_digest.is_some());
2640 assert!(wb.requested_model.is_none());
2641 }
2642
2643 #[tokio::test]
2644 async fn launch_without_run_ctx_appends_no_step_entries() {
2645 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2649 Ok(WorkerResult {
2650 value: json!(inv.prompt),
2651 ok: true,
2652 stats: None,
2653 })
2654 });
2655 let svc = build_service(factory);
2656 let blueprint = bp(
2657 step("echo", path("$.input"), path("$.out")),
2658 vec![agent("echo", "echo")],
2659 );
2660 let input = launch_input(blueprint, json!({ "input": "hi" }));
2661 assert!(
2662 input.run_ctx.is_none(),
2663 "automate() defaults run_ctx to None"
2664 );
2665 let out = svc.launch(input).await.expect("launch ok");
2666 assert_eq!(out.final_ctx["out"], "hi");
2667 }
2668
2669 #[tokio::test]
2675 async fn launch_with_task_input_leaves_init_ctx_object_seed_unmutated() {
2676 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2683 Ok(WorkerResult {
2684 value: json!({ "echoed": inv.prompt }),
2685 ok: true,
2686 stats: None,
2687 })
2688 });
2689 let svc = build_service(factory);
2690 let blueprint = bp(
2691 step("echo", path("$.input"), path("$.out")),
2692 vec![agent("echo", "echo")],
2693 );
2694 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2695 input.task_input = Some(TaskInputSpec {
2696 project_root: Some("/repo".to_string()),
2697 work_dir: Some("/repo/work".to_string()),
2698 task_metadata: Some(json!({ "issue": 19 })),
2699 });
2700 let out = svc.launch(input).await.expect("launch ok");
2701 assert_eq!(out.final_ctx["out"]["echoed"], "hi");
2702 assert!(
2703 out.final_ctx.get("project_root").is_none(),
2704 "task_input must not be folded into the flow-ir ctx seed, got {:?}",
2705 out.final_ctx
2706 );
2707 assert!(out.final_ctx.get("work_dir").is_none());
2708 assert!(out.final_ctx.get("task_metadata").is_none());
2709 }
2710
2711 #[tokio::test]
2712 async fn launch_with_task_input_none_is_a_no_op() {
2713 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2714 Ok(WorkerResult {
2715 value: json!(inv.prompt),
2716 ok: true,
2717 stats: None,
2718 })
2719 });
2720 let svc = build_service(factory);
2721 let blueprint = bp(
2722 step("echo", path("$.input"), path("$.out")),
2723 vec![agent("echo", "echo")],
2724 );
2725 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2726 assert!(input.task_input.is_none(), "automate() defaults to None");
2727 input.task_input = None;
2728 let out = svc.launch(input).await.expect("launch ok");
2729 assert_eq!(out.final_ctx["out"], "hi");
2730 }
2731
2732 #[tokio::test]
2733 async fn launch_with_task_input_all_fields_absent_is_a_no_op() {
2734 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2738 Ok(WorkerResult {
2739 value: json!(inv.prompt),
2740 ok: true,
2741 stats: None,
2742 })
2743 });
2744 let svc = build_service(factory);
2745 let blueprint = bp(
2746 step("echo", path("$.input"), path("$.out")),
2747 vec![agent("echo", "echo")],
2748 );
2749 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
2750 input.task_input = Some(TaskInputSpec::default());
2751 let out = svc.launch(input).await.expect("launch ok");
2752 assert_eq!(out.final_ctx["out"], "hi");
2753 }
2754
2755 #[test]
2760 fn merge_init_ctx_bp_default_only_passes_through_when_task_is_empty_object() {
2761 let bp_default = json!({ "seeded": "from-bp" });
2762 let task = json!({});
2763 let merged = merge_init_ctx(Some(&bp_default), &task);
2764 assert_eq!(merged, json!({ "seeded": "from-bp" }));
2765 }
2766
2767 #[test]
2768 fn merge_init_ctx_task_only_passes_through_when_bp_default_is_empty_object() {
2769 let bp_default = json!({});
2770 let task = json!({ "seeded": "from-task" });
2771 let merged = merge_init_ctx(Some(&bp_default), &task);
2772 assert_eq!(merged, json!({ "seeded": "from-task" }));
2773 }
2774
2775 #[test]
2776 fn merge_init_ctx_both_objects_task_wins_on_key_collision() {
2777 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2778 let task = json!({ "a": "task", "c": "task-only" });
2779 let merged = merge_init_ctx(Some(&bp_default), &task);
2780 assert_eq!(
2781 merged,
2782 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
2783 );
2784 }
2785
2786 #[test]
2787 fn merge_init_ctx_non_object_task_fully_replaces_bp_default() {
2788 let bp_default = json!({ "seeded": "from-bp" });
2789 let task = json!("plain-string-seed");
2790 let merged = merge_init_ctx(Some(&bp_default), &task);
2791 assert_eq!(merged, json!("plain-string-seed"));
2792 }
2793
2794 #[test]
2795 fn merge_init_ctx_no_bp_default_is_a_no_op() {
2796 let task = json!({ "input": "hi" });
2797 let merged = merge_init_ctx(None, &task);
2798 assert_eq!(merged, task);
2799 }
2800
2801 #[test]
2806 fn merge_init_ctx_3layer_no_run_override_equals_bp_task_merge_only() {
2807 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2811 let task = json!({ "a": "task", "c": "task-only" });
2812 let three_layer = merge_init_ctx_3layer(Some(&bp_default), &task, None);
2813 let two_layer = merge_init_ctx(Some(&bp_default), &task);
2814 assert_eq!(three_layer, two_layer);
2815 assert_eq!(
2816 three_layer,
2817 json!({ "a": "task", "b": "bp-only", "c": "task-only" })
2818 );
2819 }
2820
2821 #[test]
2822 fn merge_init_ctx_3layer_run_object_wins_on_key_collision_over_bp_and_task() {
2823 let bp_default = json!({ "a": "bp", "b": "bp-only" });
2824 let task = json!({ "a": "task", "c": "task-only" });
2825 let run_override = json!({ "a": "run", "d": "run-only" });
2826 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
2827 assert_eq!(
2828 merged,
2829 json!({ "a": "run", "b": "bp-only", "c": "task-only", "d": "run-only" }),
2830 "Run wins on collision (a); BP-only (b) and Task-only (c) keys survive"
2831 );
2832 }
2833
2834 #[test]
2835 fn merge_init_ctx_3layer_run_non_object_fully_replaces_bp_task_merge() {
2836 let bp_default = json!({ "seeded": "from-bp" });
2837 let task = json!({ "seeded": "from-task" });
2838 let run_override = json!("plain-string-run-seed");
2839 let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
2840 assert_eq!(merged, json!("plain-string-run-seed"));
2841 }
2842
2843 #[test]
2844 fn merge_init_ctx_3layer_no_bp_default_and_no_run_override_is_task_passthrough() {
2845 let task = json!({ "input": "hi" });
2846 let merged = merge_init_ctx_3layer(None, &task, None);
2847 assert_eq!(merged, task);
2848 }
2849
2850 #[tokio::test]
2851 async fn launch_merges_bp_default_init_ctx_into_task_init_ctx() {
2852 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
2855 Ok(WorkerResult {
2856 value: json!(inv.prompt),
2857 ok: true,
2858 stats: None,
2859 })
2860 });
2861 let svc = build_service(factory);
2862 let mut blueprint = bp(
2863 step("echo", path("$.greeting"), path("$.out")),
2864 vec![agent("echo", "echo")],
2865 );
2866 blueprint.default_init_ctx = Some(json!({ "greeting": "hello from bp" }));
2867 let out = svc
2869 .launch(launch_input(blueprint, json!({})))
2870 .await
2871 .expect("launch ok");
2872 assert_eq!(out.final_ctx["out"], "hello from bp");
2873 }
2874
2875 fn agent_with_meta(name: &str, fn_id: &str, meta: AgentMeta) -> AgentDef {
2880 AgentDef {
2881 name: name.to_string(),
2882 kind: AgentKind::RustFn,
2883 spec: json!({ "fn_id": fn_id }),
2884 profile: None,
2885 meta: Some(meta),
2886 runner: None,
2887 runner_ref: None,
2888 verdict: None,
2889 lints: None,
2890 }
2891 }
2892
2893 #[test]
2894 fn derive_agent_ctx_empty_blueprint_yields_empty_state() {
2895 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2896 let (global, per_agent) = derive_agent_ctx(&blueprint);
2897 assert_eq!(global, None);
2898 assert!(per_agent.is_empty());
2899 }
2900
2901 #[test]
2902 fn derive_agent_ctx_populated_blueprint_yields_correct_maps() {
2903 let mut blueprint = bp(
2904 step("echo", path("$.in"), path("$.out")),
2905 vec![
2906 agent_with_meta(
2907 "with-ctx",
2908 "echo",
2909 AgentMeta {
2910 ctx: Some(json!({ "org_conventions": "x" })),
2911 ..Default::default()
2912 },
2913 ),
2914 agent("no-ctx", "echo"),
2915 ],
2916 );
2917 blueprint.default_agent_ctx = Some(json!({ "seeded": "from-bp" }));
2918 let (global, per_agent) = derive_agent_ctx(&blueprint);
2919 assert_eq!(global, Some(json!({ "seeded": "from-bp" })));
2920 assert_eq!(
2921 per_agent.len(),
2922 1,
2923 "agents without AgentMeta.ctx are absent, not defaulted to null: {per_agent:?}"
2924 );
2925 assert_eq!(
2926 per_agent.get("with-ctx"),
2927 Some(&json!({ "org_conventions": "x" }))
2928 );
2929 assert!(!per_agent.contains_key("no-ctx"));
2930 }
2931
2932 #[test]
2933 fn derive_context_policies_empty_blueprint_yields_empty_state() {
2934 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2935 let (default_policy, per_agent) = derive_context_policies(&blueprint);
2936 assert_eq!(default_policy, None);
2937 assert!(per_agent.is_empty());
2938 }
2939
2940 #[test]
2941 fn derive_context_policies_populated_blueprint_yields_correct_maps() {
2942 let mut blueprint = bp(
2943 step("echo", path("$.in"), path("$.out")),
2944 vec![
2945 agent_with_meta(
2946 "with-policy",
2947 "echo",
2948 AgentMeta {
2949 context_policy: Some(ContextPolicy {
2950 include: None,
2951 exclude: vec!["work_dir".to_string()],
2952 ..Default::default()
2953 }),
2954 ..Default::default()
2955 },
2956 ),
2957 agent("no-policy", "echo"),
2958 ],
2959 );
2960 blueprint.default_context_policy = Some(ContextPolicy {
2961 include: Some(vec!["project_root".to_string()]),
2962 exclude: vec![],
2963 ..Default::default()
2964 });
2965 let (default_policy, per_agent) = derive_context_policies(&blueprint);
2966 assert_eq!(
2967 default_policy,
2968 Some(ContextPolicy {
2969 include: Some(vec!["project_root".to_string()]),
2970 exclude: vec![],
2971 ..Default::default()
2972 })
2973 );
2974 assert_eq!(per_agent.len(), 1);
2975 assert_eq!(
2976 per_agent.get("with-policy"),
2977 Some(&ContextPolicy {
2978 include: None,
2979 exclude: vec!["work_dir".to_string()],
2980 ..Default::default()
2981 })
2982 );
2983 assert!(!per_agent.contains_key("no-policy"));
2984 }
2985
2986 #[test]
2992 fn derive_step_metas_empty_blueprint_yields_empty_map() {
2993 let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
2994 assert!(derive_step_metas(&blueprint).is_empty());
2995 }
2996
2997 #[test]
2998 fn derive_step_metas_populated_blueprint_yields_name_to_ctx_map() {
2999 let mut blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
3000 blueprint.metas = vec![
3001 MetaDef {
3002 name: "heavy-scan".to_string(),
3003 ctx: json!({ "work_dir": "/x" }),
3004 },
3005 MetaDef {
3006 name: "light-scan".to_string(),
3007 ctx: json!({ "work_dir": "/y" }),
3008 },
3009 ];
3010 let metas = derive_step_metas(&blueprint);
3011 assert_eq!(metas.len(), 2);
3012 assert_eq!(metas.get("heavy-scan"), Some(&json!({ "work_dir": "/x" })));
3013 assert_eq!(metas.get("light-scan"), Some(&json!({ "work_dir": "/y" })));
3014 }
3015
3016 #[test]
3017 fn derive_agent_ctx_meta_ref_resolves_as_base_under_inline_ctx() {
3018 let mut blueprint = bp(
3019 step("echo", path("$.in"), path("$.out")),
3020 vec![agent_with_meta(
3021 "with-meta-ref",
3022 "echo",
3023 AgentMeta {
3024 ctx: Some(json!({ "work_dir": "/inline-wins" })),
3025 meta_ref: Some("shared".to_string()),
3026 ..Default::default()
3027 },
3028 )],
3029 );
3030 blueprint.metas = vec![MetaDef {
3031 name: "shared".to_string(),
3032 ctx: json!({ "work_dir": "/base", "extra": "from-pool" }),
3033 }];
3034 let (_, per_agent) = derive_agent_ctx(&blueprint);
3035 assert_eq!(
3036 per_agent.get("with-meta-ref"),
3037 Some(&json!({ "work_dir": "/inline-wins", "extra": "from-pool" })),
3038 "inline ctx must win the collided key while pool-only keys survive the merge"
3039 );
3040 }
3041
3042 #[test]
3043 fn derive_agent_ctx_meta_ref_alone_uses_pool_ctx_verbatim() {
3044 let mut blueprint = bp(
3045 step("echo", path("$.in"), path("$.out")),
3046 vec![agent_with_meta(
3047 "with-meta-ref-only",
3048 "echo",
3049 AgentMeta {
3050 meta_ref: Some("shared".to_string()),
3051 ..Default::default()
3052 },
3053 )],
3054 );
3055 blueprint.metas = vec![MetaDef {
3056 name: "shared".to_string(),
3057 ctx: json!({ "work_dir": "/base" }),
3058 }];
3059 let (_, per_agent) = derive_agent_ctx(&blueprint);
3060 assert_eq!(
3061 per_agent.get("with-meta-ref-only"),
3062 Some(&json!({ "work_dir": "/base" }))
3063 );
3064 }
3065
3066 #[test]
3067 fn derive_agent_ctx_unresolved_meta_ref_never_panics_and_falls_back_to_inline() {
3068 let blueprint = bp(
3069 step("echo", path("$.in"), path("$.out")),
3070 vec![agent_with_meta(
3071 "with-unresolved-meta-ref",
3072 "echo",
3073 AgentMeta {
3074 ctx: Some(json!({ "work_dir": "/inline-only" })),
3075 meta_ref: Some("missing".to_string()),
3076 ..Default::default()
3077 },
3078 )],
3079 );
3080 let (_, per_agent) = derive_agent_ctx(&blueprint);
3082 assert_eq!(
3083 per_agent.get("with-unresolved-meta-ref"),
3084 Some(&json!({ "work_dir": "/inline-only" })),
3085 "an unresolved meta_ref must never panic; the agent's own inline ctx still applies"
3086 );
3087 }
3088
3089 #[test]
3105 fn resolve_runner_legacy_fallback_matches_derive_worker_bindings_semantics() {
3106 fn legacy_agent(name: &str, variant: &str, tools: Vec<&str>) -> AgentDef {
3107 AgentDef {
3108 name: name.to_string(),
3109 kind: AgentKind::Operator,
3110 spec: json!({}),
3111 profile: Some(AgentProfile {
3112 worker_binding: Some(variant.to_string()),
3113 tools: tools.into_iter().map(str::to_string).collect(),
3114 ..Default::default()
3115 }),
3116 meta: None,
3117 runner: None,
3118 runner_ref: None,
3119 verdict: None,
3120 lints: None,
3121 }
3122 }
3123
3124 let blueprint = bp(
3125 step("planner", path("$.in"), path("$.out")),
3126 vec![
3127 legacy_agent("planner", "planning-worker", vec!["Read", "Grep"]),
3128 legacy_agent("coder", "code-worker", vec![]),
3129 agent("no-binding", "echo"),
3130 ],
3131 );
3132
3133 let derived = derive_worker_bindings(&blueprint);
3134
3135 for agent_def in &blueprint.agents {
3136 let resolved = resolve_runner(&blueprint, agent_def).expect("no unresolved refs");
3137 match derived.get(&agent_def.name) {
3138 Some(binding) => {
3139 assert_eq!(
3140 resolved,
3141 Some(Runner::WsClaudeCode {
3142 variant: binding.variant.clone(),
3143 tools: binding.tools.clone(),
3144 }),
3145 "resolve_runner must synthesize the same WsClaudeCode Runner \
3146 derive_worker_bindings produces for agent '{}'",
3147 agent_def.name
3148 );
3149 }
3150 None => {
3151 assert_eq!(
3152 resolved, None,
3153 "agent '{}' has no derive_worker_bindings entry, so resolve_runner \
3154 must resolve to None too (no other tier declared)",
3155 agent_def.name
3156 );
3157 }
3158 }
3159 }
3160 }
3161
3162 #[test]
3163 fn ws_operator_runner_projects_into_the_existing_spawn_binding() {
3164 let mut blueprint = bp(
3165 step("reviewer", path("$.in"), path("$.out")),
3166 vec![agent("reviewer", "echo")],
3167 );
3168 blueprint.agents[0].runner = Some(Runner::WsOperator {
3169 variant: "mse-reviewer".to_string(),
3170 tools: vec!["Read".to_string(), "Grep".to_string()],
3171 });
3172
3173 let derived = derive_worker_bindings(&blueprint);
3174 let binding = derived
3175 .get("reviewer")
3176 .expect("ws_operator must feed the canonical spawn binding path");
3177 assert_eq!(binding.variant, "mse-reviewer");
3178 assert_eq!(binding.tools, ["Read", "Grep"]);
3179 }
3180
3181 fn counting_echo_service() -> (TaskLaunchService, Arc<std::sync::atomic::AtomicUsize>) {
3190 use std::sync::atomic::{AtomicUsize, Ordering};
3191 let calls = Arc::new(AtomicUsize::new(0));
3192 let counter = calls.clone();
3193 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", move |inv| {
3194 let counter = counter.clone();
3195 async move {
3196 counter.fetch_add(1, Ordering::SeqCst);
3197 Ok(WorkerResult {
3198 value: json!({ "echoed": inv.prompt }),
3199 ok: true,
3200 stats: None,
3201 })
3202 }
3203 });
3204 (build_service(factory), calls)
3205 }
3206
3207 async fn seed_legacy_run(run_store: &Arc<dyn crate::store::run::RunStore>) -> crate::RunId {
3208 use crate::store::run::{RunRecord, RunStatus};
3209 use crate::types::TaskId;
3210 let run_id = crate::RunId::new();
3211 run_store
3212 .create(RunRecord {
3213 id: run_id.clone(),
3214 task_id: TaskId::new(),
3215 status: RunStatus::Running,
3216 step_entries: Vec::new(),
3217 degradations: Vec::new(),
3218 operator_sid: None,
3219 current: Default::default(),
3220 next_generation: 0,
3221 result_ref: None,
3222 input_json: Some("{}".to_string()),
3225 created_at: 0,
3226 updated_at: 0,
3227 })
3228 .await
3229 .expect("seed legacy RunRecord");
3230 run_id
3231 }
3232
3233 #[tokio::test]
3239 async fn backfilled_run_replays_legacy_keys_stably_across_two_resumes() {
3240 use crate::store::replay::{InMemoryReplayStore, ReplayCursor, ReplayStore};
3241 use crate::store::run::{InMemoryRunStore, RunContext, RunStore};
3242 use std::sync::atomic::Ordering;
3243 use std::sync::Mutex;
3244
3245 let (svc, echo_calls) = counting_echo_service();
3246 let blueprint = bp(
3247 step("echo", path("$.input"), path("$.out")),
3248 vec![agent("echo", "echo")],
3249 );
3250 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3251 let replay_store: Arc<dyn ReplayStore> = Arc::new(InMemoryReplayStore::new());
3252 let run_id = seed_legacy_run(&run_store).await;
3253
3254 let rc1 = RunContext::new(run_id.clone(), run_store.clone())
3258 .with_replay_store(replay_store.clone())
3259 .with_resume();
3260 let mut input1 = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3261 input1.run_ctx = Some(rc1);
3262 let out1 = svc.launch(input1).await.expect("phase-1 resume launch ok");
3263 assert_eq!(out1.final_ctx["out"]["echoed"], "hi");
3264 assert_eq!(
3265 echo_calls.load(Ordering::SeqCst),
3266 1,
3267 "phase 1 dispatches the worker once (nothing to replay yet)"
3268 );
3269 let entries = replay_store
3270 .list_by_run(&run_id)
3271 .await
3272 .expect("list replay rows");
3273 assert_eq!(
3274 entries.len(),
3275 1,
3276 "phase 1 must log exactly one legacy-hashed replay row"
3277 );
3278 let run = run_store.get(&run_id).await.expect("run present");
3279 let snap: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3280 assert_eq!(
3281 SnapshotOrigin::from_snapshot(&snap),
3282 SnapshotOrigin::ResumeBackfill,
3283 "phase 1 must pin the snapshot as resume_backfill"
3284 );
3285
3286 let cursor = ReplayCursor::from_entries(entries);
3291 let rc2 = RunContext::new(run_id.clone(), run_store.clone())
3292 .with_replay_store(replay_store.clone())
3293 .with_replay_cursor(Arc::new(Mutex::new(cursor)))
3294 .with_resume();
3295 let mut input2 = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3296 input2.run_ctx = Some(rc2);
3297 let out2 = svc.launch(input2).await.expect("phase-2 resume launch ok");
3298 assert_eq!(out2.final_ctx["out"]["echoed"], "hi");
3299 assert_eq!(
3300 echo_calls.load(Ordering::SeqCst),
3301 1,
3302 "phase 2 must REPLAY the legacy-hashed row — the worker must not run again"
3303 );
3304 let run2 = run_store.get(&run_id).await.expect("run present");
3305 let snap2: Value = serde_json::from_str(run2.input_json.as_deref().unwrap()).unwrap();
3306 assert_eq!(
3307 SnapshotOrigin::from_snapshot(&snap2),
3308 SnapshotOrigin::ResumeBackfill,
3309 "origin must stay resume_backfill across resumes (replay key stability)"
3310 );
3311 }
3312
3313 #[tokio::test]
3318 async fn launch_origin_run_uses_digest_keys_and_misses_legacy_replay_row() {
3319 use crate::store::replay::{InMemoryReplayStore, ReplayCursor, ReplayStore};
3320 use crate::store::run::{InMemoryRunStore, RunContext, RunStore};
3321 use std::sync::atomic::Ordering;
3322 use std::sync::Mutex;
3323
3324 let (svc, echo_calls) = counting_echo_service();
3325 let blueprint = bp(
3326 step("echo", path("$.input"), path("$.out")),
3327 vec![agent("echo", "echo")],
3328 );
3329 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3330 let replay_store: Arc<dyn ReplayStore> = Arc::new(InMemoryReplayStore::new());
3331
3332 let backfill_run = seed_legacy_run(&run_store).await;
3334 let rc_bf = RunContext::new(backfill_run.clone(), run_store.clone())
3335 .with_replay_store(replay_store.clone())
3336 .with_resume();
3337 let mut input_bf = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3338 input_bf.run_ctx = Some(rc_bf);
3339 svc.launch(input_bf).await.expect("backfill launch ok");
3340 assert_eq!(echo_calls.load(Ordering::SeqCst), 1);
3341 let legacy_entries = replay_store
3342 .list_by_run(&backfill_run)
3343 .await
3344 .expect("list legacy rows");
3345 assert_eq!(legacy_entries.len(), 1);
3346
3347 let launch_run = seed_legacy_run(&run_store).await;
3351 let cursor = ReplayCursor::from_entries(legacy_entries);
3352 let rc_launch = RunContext::new(launch_run.clone(), run_store.clone())
3353 .with_replay_store(replay_store.clone())
3354 .with_replay_cursor(Arc::new(Mutex::new(cursor)));
3355 let mut input_launch = launch_input(blueprint.clone(), json!({ "input": "hi" }));
3356 input_launch.run_ctx = Some(rc_launch);
3357 svc.launch(input_launch)
3358 .await
3359 .expect("launch-origin launch ok");
3360 assert_eq!(
3361 echo_calls.load(Ordering::SeqCst),
3362 2,
3363 "a launch-origin Run keys replay by binding digest, so the \
3364 legacy-hashed row must MISS and the worker must run"
3365 );
3366 let run = run_store.get(&launch_run).await.expect("run present");
3367 let snap: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
3368 assert_eq!(SnapshotOrigin::from_snapshot(&snap), SnapshotOrigin::Launch);
3369 }
3370
3371 #[test]
3379 fn task_launch_error_flow_eval_struct_variant_display_preserves_prefix() {
3380 let err = TaskLaunchError::FlowEval {
3381 message: "dispatcher error at ref foo".to_string(),
3382 failed_step: Some("foo".to_string()),
3383 verdict_value: Some(json!({"verdict": "BLOCKED"})),
3384 partial_ctx: Some(json!({"steps": {}})),
3385 };
3386 assert_eq!(err.to_string(), "flow eval: dispatcher error at ref foo");
3387
3388 let err_bare = TaskLaunchError::FlowEval {
3392 message: "unresolved extern".to_string(),
3393 failed_step: None,
3394 verdict_value: None,
3395 partial_ctx: None,
3396 };
3397 assert_eq!(err_bare.to_string(), "flow eval: unresolved extern");
3398 }
3399
3400 #[tokio::test]
3405 async fn task_launch_flow_eval_error_carries_failed_step_and_verdict_value() {
3406 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
3407 use crate::types::{RunId, TaskId};
3408
3409 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |_inv| async move {
3410 Ok(WorkerResult {
3411 value: json!({ "verdict": "BLOCKED", "reason": "not applicable" }),
3412 ok: false,
3413 stats: None,
3414 })
3415 });
3416 let svc = build_service(factory);
3417 let blueprint = bp(
3418 step("gate", path("$.input"), path("$.out")),
3419 vec![agent("gate", "gate")],
3420 );
3421
3422 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3423 let run_id = RunId::new();
3424 run_store
3425 .create(RunRecord {
3426 id: run_id.clone(),
3427 task_id: TaskId::new(),
3428 status: RunStatus::Running,
3429 step_entries: Vec::new(),
3430 degradations: Vec::new(),
3431 operator_sid: None,
3432 current: Default::default(),
3433 next_generation: 0,
3434 result_ref: None,
3435 input_json: Some("{}".to_string()),
3436 created_at: 0,
3437 updated_at: 0,
3438 })
3439 .await
3440 .expect("seed RunRecord");
3441
3442 let mut input = launch_input(blueprint, json!({ "input": "hi" }));
3443 input.run_ctx = Some(RunContext::new(run_id, run_store));
3444
3445 let err = svc.launch(input).await.expect_err("expected FlowEval");
3446 match err {
3447 TaskLaunchError::FlowEval {
3448 message,
3449 failed_step,
3450 verdict_value,
3451 partial_ctx,
3452 } => {
3453 assert!(
3454 message.contains("blocked"),
3455 "expected message to mention blocked, got: {message}"
3456 );
3457 assert_eq!(
3458 failed_step,
3459 Some("gate".to_string()),
3460 "failed_step should be the Blueprint step ref, not the opaque StepId"
3461 );
3462 let vv = verdict_value.expect("verdict_value must be Some for Blocked");
3463 assert_eq!(vv["verdict"], "BLOCKED");
3464 assert_eq!(vv["reason"], "not applicable");
3465 assert!(
3469 partial_ctx.is_some(),
3470 "partial_ctx must be Some when a RunContext was supplied"
3471 );
3472 }
3473 other => panic!("expected FlowEval, got {other:?}"),
3474 }
3475 }
3476
3477 #[tokio::test]
3483 async fn task_launch_flow_eval_error_partial_ctx_reconstructs_from_run_store() {
3484 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
3485 use crate::types::{RunId, TaskId};
3486
3487 let factory = RustFnInProcessSpawnerFactory::new()
3488 .register_fn("upper", |inv| async move {
3489 Ok(WorkerResult {
3490 value: json!(inv.prompt.to_uppercase()),
3491 ok: true,
3492 stats: None,
3493 })
3494 })
3495 .register_fn("gate", |_inv| async move {
3496 Ok(WorkerResult {
3497 value: json!({ "verdict": "BLOCKED" }),
3498 ok: false,
3499 stats: None,
3500 })
3501 })
3502 .register_fn("never", |inv| async move {
3503 Ok(WorkerResult {
3504 value: json!(inv.prompt),
3505 ok: true,
3506 stats: None,
3507 })
3508 });
3509 let svc = build_service(factory);
3510 let flow = FlowNode::Seq {
3511 children: vec![
3512 step("upper", path("$.in"), path("$.s1")),
3513 step("gate", path("$.s1"), path("$.s2")),
3514 step("never", path("$.s2"), path("$.s3")),
3515 ],
3516 };
3517 let blueprint = bp(
3518 flow,
3519 vec![
3520 agent("upper", "upper"),
3521 agent("gate", "gate"),
3522 agent("never", "never"),
3523 ],
3524 );
3525
3526 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3527 let run_id = RunId::new();
3528 run_store
3529 .create(RunRecord {
3530 id: run_id.clone(),
3531 task_id: TaskId::new(),
3532 status: RunStatus::Running,
3533 step_entries: Vec::new(),
3534 degradations: Vec::new(),
3535 operator_sid: None,
3536 current: Default::default(),
3537 next_generation: 0,
3538 result_ref: None,
3539 input_json: Some("{}".to_string()),
3540 created_at: 0,
3541 updated_at: 0,
3542 })
3543 .await
3544 .expect("seed RunRecord");
3545
3546 let mut input = launch_input(blueprint, json!({ "in": "hi" }));
3547 input.run_ctx = Some(RunContext::new(run_id.clone(), run_store.clone()));
3548
3549 let err = svc.launch(input).await.expect_err("expected FlowEval");
3550 let partial_ctx = match err {
3551 TaskLaunchError::FlowEval { partial_ctx, .. } => {
3552 partial_ctx.expect("partial_ctx must be Some")
3553 }
3554 other => panic!("expected FlowEval, got {other:?}"),
3555 };
3556 let steps = partial_ctx
3557 .get("steps")
3558 .and_then(|v| v.as_object())
3559 .expect("partial_ctx.steps object");
3560 assert_eq!(
3564 steps.len(),
3565 2,
3566 "expected 2 step_entries (upper passed + gate blocked), got: {steps:?}"
3567 );
3568 let mut status_by_ref: HashMap<String, String> = HashMap::new();
3569 for (_step_id, entry) in steps {
3570 let step_ref = entry
3571 .get("step_ref")
3572 .and_then(|v| v.as_str())
3573 .expect("step_ref present")
3574 .to_string();
3575 let status = entry
3576 .get("status")
3577 .and_then(|v| v.as_str())
3578 .expect("status present")
3579 .to_string();
3580 status_by_ref.insert(step_ref, status);
3581 }
3582 assert_eq!(
3583 status_by_ref.get("upper").map(String::as_str),
3584 Some("passed")
3585 );
3586 assert_eq!(
3587 status_by_ref.get("gate").map(String::as_str),
3588 Some("blocked")
3589 );
3590 assert!(
3591 !status_by_ref.contains_key("never"),
3592 "step 'never' must not appear — flow-ir stops dispatching after Blocked abort"
3593 );
3594 }
3595
3596 #[tokio::test]
3602 async fn task_launch_flow_eval_error_without_run_ctx_has_none_fields() {
3603 let factory = RustFnInProcessSpawnerFactory::new().register_fn("gate", |_inv| async move {
3604 Ok(WorkerResult {
3605 value: json!({ "verdict": "BLOCKED" }),
3606 ok: false,
3607 stats: None,
3608 })
3609 });
3610 let svc = build_service(factory);
3611 let blueprint = bp(
3612 step("gate", path("$.input"), path("$.out")),
3613 vec![agent("gate", "gate")],
3614 );
3615 let err = svc
3616 .launch(launch_input(blueprint, json!({ "input": "hi" })))
3617 .await
3618 .expect_err("expected FlowEval");
3619 match err {
3620 TaskLaunchError::FlowEval {
3621 failed_step,
3622 verdict_value,
3623 partial_ctx,
3624 ..
3625 } => {
3626 assert_eq!(
3627 failed_step, None,
3628 "failed_step must be None without run_ctx"
3629 );
3630 assert_eq!(
3631 verdict_value, None,
3632 "verdict_value must be None without run_ctx"
3633 );
3634 assert_eq!(
3635 partial_ctx, None,
3636 "partial_ctx must be None without run_ctx"
3637 );
3638 }
3639 other => panic!("expected FlowEval, got {other:?}"),
3640 }
3641 }
3642}