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