1use async_trait::async_trait;
14use mlua_swarm::core::agent_context::AgentContextView;
15use mlua_swarm::core::projection::{
16 FileProjectionAdapter, ProjectionAdapter, ProjectionKey, ProjectionRef,
17};
18use mlua_swarm::{
19 CapToken, Ctx, Operator, SeniorBridge, SessionId, SpawnHook, StepId, WorkerBinding,
20 WorkerError, WorkerResult,
21};
22use serde_json::Value;
23use std::collections::HashMap;
24use tokio::sync::{mpsc, oneshot, Mutex};
25
26use super::protocol::{current_parent_req_id, PendingReply, ServerMsg};
27
28pub struct WSOperatorSession {
30 sid: SessionId,
31 tx: Mutex<Option<mpsc::UnboundedSender<ServerMsg>>>,
34 pending: Mutex<HashMap<String, oneshot::Sender<PendingReply>>>,
37 base_url: Option<std::sync::Arc<str>>,
43}
44
45impl WSOperatorSession {
46 pub(super) fn new_with_base_url(
56 sid: SessionId,
57 tx: mpsc::UnboundedSender<ServerMsg>,
58 base_url: Option<std::sync::Arc<str>>,
59 ) -> Self {
60 Self {
61 sid,
62 tx: Mutex::new(Some(tx)),
63 pending: Mutex::new(HashMap::new()),
64 base_url,
65 }
66 }
67
68 pub(super) async fn replace_tx(&self, new_tx: mpsc::UnboundedSender<ServerMsg>) {
70 *self.tx.lock().await = Some(new_tx);
71 }
72
73 pub(crate) async fn clear_tx(&self) {
75 *self.tx.lock().await = None;
76 }
77
78 pub(super) async fn resolve_pending(&self, req_id: &str, reply: PendingReply) {
81 if let Some(otx) = self.pending.lock().await.remove(req_id) {
82 let _ = otx.send(reply);
83 }
84 }
85
86 async fn send_and_await(&self, req_id: String, msg: ServerMsg) -> Result<PendingReply, String> {
90 let (otx, orx) = oneshot::channel::<PendingReply>();
91 self.pending.lock().await.insert(req_id.clone(), otx);
92
93 let send_result = {
95 let guard = self.tx.lock().await;
96 match guard.as_ref() {
97 Some(tx) => tx
98 .send(msg)
99 .map_err(|_| "ws send channel closed".to_string()),
100 None => Err("ws operator disconnected".to_string()),
101 }
102 };
103 if let Err(e) = send_result {
104 self.pending.lock().await.remove(&req_id);
105 return Err(e);
106 }
107
108 orx.await
109 .map_err(|_| "ws operator: oneshot cancelled (= reply path closed)".to_string())
110 }
111
112 async fn send_oneway(&self, msg: ServerMsg) -> Result<(), String> {
114 let guard = self.tx.lock().await;
115 match guard.as_ref() {
116 Some(tx) => tx
117 .send(msg)
118 .map_err(|_| "ws send channel closed".to_string()),
119 None => Err("ws operator disconnected".to_string()),
120 }
121 }
122}
123
124#[async_trait]
125impl SeniorBridge for WSOperatorSession {
126 async fn ask(&self, task_id: &StepId, question: Value) -> Result<Value, String> {
127 let req_id = format!("{}-ask-{}", self.sid, uuid::Uuid::new_v4());
128 let msg = ServerMsg::Ask {
129 req_id: req_id.clone(),
130 parent_req_id: current_parent_req_id(),
131 task_id: task_id.clone(),
132 question,
133 };
134 match self.send_and_await(req_id, msg).await? {
135 PendingReply::Answer(v) => Ok(v),
136 PendingReply::HookAck { .. } => {
137 Err("ws operator: unexpected hook_ack reply to ask".into())
138 }
139 PendingReply::SpawnAck { .. } => {
140 Err("ws operator: unexpected spawn_ack reply to ask".into())
141 }
142 PendingReply::SpawnHalt { .. } => {
143 Err("ws operator: unexpected spawn_halt reply to ask".into())
144 }
145 }
146 }
147}
148
149#[async_trait]
150impl SpawnHook for WSOperatorSession {
151 async fn before(&self, ctx: &Ctx) -> Result<(), String> {
152 let req_id = format!("{}-hb-{}", self.sid, uuid::Uuid::new_v4());
153 let msg = ServerMsg::HookBefore {
154 req_id: req_id.clone(),
155 parent_req_id: current_parent_req_id(),
156 task_id: ctx.task_id.clone(),
157 agent: ctx.agent.clone(),
158 attempt: ctx.attempt,
159 };
160 match self.send_and_await(req_id, msg).await? {
161 PendingReply::HookAck { ok: true, .. } => Ok(()),
162 PendingReply::HookAck { ok: false, reason } => {
163 Err(reason.unwrap_or_else(|| "ws operator: spawn rejected".into()))
164 }
165 PendingReply::Answer(_) => {
166 Err("ws operator: unexpected answer reply to hook_before".into())
167 }
168 PendingReply::SpawnAck { .. } => {
169 Err("ws operator: unexpected spawn_ack reply to hook_before".into())
170 }
171 PendingReply::SpawnHalt { .. } => {
172 Err("ws operator: unexpected spawn_halt reply to hook_before".into())
173 }
174 }
175 }
176
177 async fn after(&self, ctx: &Ctx, result: &Value) -> Result<(), String> {
178 let req_id = format!("{}-ha-{}", self.sid, uuid::Uuid::new_v4());
179 let msg = ServerMsg::HookAfter {
180 req_id,
181 parent_req_id: current_parent_req_id(),
182 task_id: ctx.task_id.clone(),
183 agent: ctx.agent.clone(),
184 attempt: ctx.attempt,
185 result: result.clone(),
186 };
187 let _ = self.send_oneway(msg).await;
189 Ok(())
190 }
191}
192
193#[async_trait]
194impl Operator for WSOperatorSession {
195 async fn execute(
218 &self,
219 ctx: &Ctx,
220 _system: Option<String>,
221 prompt: Value,
222 worker: Option<WorkerBinding>,
223 worker_token: CapToken,
224 ) -> Result<WorkerResult, WorkerError> {
225 let Some(worker) = worker else {
226 return Err(WorkerError::Failed(format!(
227 "agent '{}' has no worker_binding; WS thin-path requires one \
228 (Blueprint AgentDef.profile.worker_binding)",
229 ctx.agent
230 )));
231 };
232 let req_id = format!("{}-spawn-{}", self.sid, uuid::Uuid::new_v4());
233 let worker_handle = ctx
234 .meta
235 .runtime
236 .get("worker_handle")
237 .and_then(|v| v.as_str())
238 .map(|s| s.to_string());
239 let data_sink_endpoint = ctx
240 .meta
241 .runtime
242 .get("data_sink_endpoint")
243 .and_then(|v| v.as_str());
244 let run_id = ctx.meta.runtime.get("run_id").and_then(|v| v.as_str());
249 let view = AgentContextView::materialized_or_from_ctx(ctx);
258 let directive = default_spawn_directive_with_task_directive(
265 &ctx.agent,
266 ctx.task_id.as_str(),
267 &worker.variant,
268 &view,
269 data_sink_endpoint,
270 self.base_url.as_deref(),
271 run_id,
272 &prompt,
273 );
274 let directive = append_projection_pointer(directive, &ctx.task_id, &view, run_id);
279 let msg = ServerMsg::Spawn {
280 req_id: req_id.clone(),
281 parent_req_id: current_parent_req_id(),
282 task_id: ctx.task_id.clone(),
283 agent: ctx.agent.clone(),
284 attempt: ctx.attempt,
285 capability_token: worker_token.encode(),
286 worker_handle,
287 worker: Some(worker),
288 directive,
289 };
290 match self.send_and_await(req_id, msg).await {
291 Ok(PendingReply::SpawnAck {
292 value,
293 ok,
294 error: None,
295 }) => Ok(WorkerResult { value, ok }),
296 Ok(PendingReply::SpawnAck {
297 error: Some(msg), ..
298 }) => Err(WorkerError::Failed(msg)),
299 Ok(PendingReply::SpawnHalt { value, reason }) => {
306 let marker = serde_json::json!({
307 "halted": true,
308 "reason": reason,
309 "value": value,
310 });
311 Ok(WorkerResult {
312 value: marker,
313 ok: true,
314 })
315 }
316 Ok(_) => Err(WorkerError::Failed(
317 "ws operator: unexpected non-spawn reply".into(),
318 )),
319 Err(e) => Err(WorkerError::Failed(format!("ws operator spawn: {e}"))),
320 }
321 }
322
323 fn requires_worker_binding(&self) -> bool {
324 true
325 }
326}
327
328#[allow(clippy::too_many_arguments)]
386pub(super) fn default_spawn_directive(
387 agent: &str,
388 task_id: &str,
389 subagent_type: &str,
390 view: &AgentContextView,
391 data_sink_endpoint: Option<&str>,
392 base_url: Option<&str>,
393 run_id: Option<&str>,
394) -> String {
395 let context_header = view.to_directive_header();
399 let data_endpoint_block = match data_sink_endpoint {
406 Some(base) => format!(
407 "\n\
408 [Data path endpoint — MainAgent reminder]\n\
409 When you kick a SubAgent, inject the following two lines into\n\
410 its prompt / environment so Big Response payloads (4k+ tokens,\n\
411 files, intermediate artifacts) flow directly to the Store owner,\n\
412 bypassing the MainAgent (context stays small; only the out_id\n\
413 ref is passed around).\n \
414 DATA_EMIT: {base}/v1/data/emit (POST, auth = Bearer worker_handle or ?token=)\n \
415 DATA_GET: {base}/v1/data/<out_id|out_name> (the next SubAgent fetches from $IN_REFS)\n\
416 When a SubAgent produces a Big Response, POST it to DATA_EMIT\n\
417 and return only the one-line out_id ref (do not mix the body\n\
418 in; the MainAgent must not answer directly).\n\
419 \n"
420 ),
421 None => String::new(),
422 };
423 let main_ai_reminder = match view.project_name_alias.as_deref() {
424 Some(a) => format!(
425 "\n\
426 [LDS Session Alias Reminder — MainAI mandatory]\n\
427 Before kicking the SubAgent below, call:\n \
428 mcp__lds__session_create(root=<working_dir>, alias=\"{a}\")\n\
429 (= establish a single task-level lds session; reuse on repeated dispatch).\n\
430 Then add this literal line to the SubAgent prompt body below:\n \
431 LDS Session Alias: {a}\n\
432 The SubAgent will call mcp__lds__session_start(alias=\"{a}\") on init,\n\
433 keeping worktree ownership unified across dispatches.\n\
434 (Full discipline rationale is inlined above; reach is via this directive itself,\n\
435 not via any external doc path. The 2 steps above are the complete contract.)\n\
436 \n"
437 ),
438 None => String::new(),
439 };
440 let base_url_line = match base_url {
444 Some(u) => u.to_string(),
445 None => "<your server's actual bind — check with mse_doctor>".to_string(),
446 };
447 let run_route_line = match run_id {
452 Some(rid) => format!("GET <base_url>/v1/runs/{rid}"),
453 None => "GET <base_url>/v1/runs/<run_id>".to_string(),
454 };
455 format!(
456 "[agent_primitive dispatch=@{agent}]\n\
457 worker endpoint:\n \
458 GET <base_url>/v1/worker/prompt?task_id={task_id}\n \
459 POST <base_url>/v1/worker/submit\n\
460 auth: Bearer <worker_handle from THIS Spawn payload (= short `wh-XXXXXXXX` form)>\n\
461 task_id: {task_id}\n\
462 agent_id: {agent}\n\
463 {context_header}\
464 {data_endpoint_block}\
465 {main_ai_reminder}\
466 Kick a SubAgent via Agent tool with subagent_type=\"{subagent_type}\" (= project-local \
467 `.claude/agents/{subagent_type}.md`, this agent's Blueprint-declared worker binding). \
468 The prompt you pass to it MUST be EXACTLY these 4 lines (no preamble, no extra text):\n\
469 \n \
470 agent_id: {agent}\n \
471 worker_handle: <THIS Spawn payload's `worker_handle` field (short string `wh-XXXXXXXX`)>\n \
472 base_url: {base_url_line}\n \
473 task_id: {task_id}\n\
474 \n\
475 The SubAgent self-fetches system + prompt via GET (Bearer = handle), \
476 executes as agent @{agent}, POSTs raw body to /v1/worker/submit (Bearer = handle, \
477 server resolves task_id from handle), and replies `OUTPUT` 1 word. You then forward \
478 SpawnAck {{req_id, value:{{}}, ok:true}} through your operator client — MCP path: \
479 mse_ack(sid, req_id, kind=\"spawn_ack\", ok=true) (= empty value because canonical \
480 body lives in output_tail via the POST). \
481 Do NOT fetch /v1/worker/prompt yourself. Do NOT wrap, summarize, or field-select \
482 the SubAgent reply. Observation / debug is a separate channel (= agent-inspect MCP / \
483 {run_route_line}), do NOT mix it into the forward path. \
484 If the SubAgent type is not registered, FAIL LOUD: reply SpawnAck ok=false with an \
485 error explaining the missing `.claude/agents/{subagent_type}.md` — do NOT fall back \
486 to another subagent_type."
487 )
488}
489
490#[allow(clippy::too_many_arguments)]
504pub(super) fn default_spawn_directive_with_task_directive(
505 agent: &str,
506 task_id: &str,
507 subagent_type: &str,
508 view: &AgentContextView,
509 data_sink_endpoint: Option<&str>,
510 base_url: Option<&str>,
511 run_id: Option<&str>,
512 task_directive: &Value,
513) -> String {
514 let base = default_spawn_directive(
515 agent,
516 task_id,
517 subagent_type,
518 view,
519 data_sink_endpoint,
520 base_url,
521 run_id,
522 );
523 let task_directive_line = match task_directive {
528 Value::Null => String::new(),
529 Value::String(s) => format!("task_directive: {s}\n"),
530 other => format!("task_directive: {other}\n"),
531 };
532 format!("{base}{task_directive_line}")
533}
534
535fn append_projection_pointer(
565 directive: String,
566 task_id: &StepId,
567 view: &AgentContextView,
568 run_id: Option<&str>,
569) -> String {
570 let Some(work_dir) = view.work_dir.as_deref() else {
571 return directive;
572 };
573 match serde_json::to_value(view) {
574 Ok(ctx_data) => {
575 let key = ProjectionKey {
576 task_id: task_id.to_string(),
577 run_id: run_id.map(str::to_string),
578 step: None,
579 path: None,
580 };
581 let adapter = FileProjectionAdapter::new(work_dir);
582 match adapter.project(&key, &ctx_data) {
583 Ok(reference) => {
584 let pointer_value = match &reference {
585 ProjectionRef::File { path } => serde_json::json!({ "file": path }),
586 ProjectionRef::Query { endpoint, key } => {
587 serde_json::json!({ "endpoint": endpoint, "key": key })
588 }
589 };
590 format!("{directive}ctx_projection: {pointer_value}\n")
591 }
592 Err(err) => {
593 tracing::warn!(
594 %task_id,
595 error = %err,
596 "projection hook: materialize failed, spawning without a pointer"
597 );
598 directive
599 }
600 }
601 }
602 Err(err) => {
603 tracing::warn!(
604 %task_id,
605 error = %err,
606 "projection hook: AgentContextView serialize failed, spawning without a pointer"
607 );
608 directive
609 }
610 }
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use mlua_swarm::core::agent_context::{
617 TASK_METADATA_KEY, TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY,
618 };
619
620 fn view_with(
627 alias: Option<&str>,
628 project_root: Option<&str>,
629 work_dir: Option<&str>,
630 ) -> AgentContextView {
631 AgentContextView {
632 project_name_alias: alias.map(String::from),
633 project_root: project_root.map(String::from),
634 work_dir: work_dir.map(String::from),
635 ..AgentContextView::default()
636 }
637 }
638
639 #[test]
640 fn directive_omits_project_name_alias_when_none() {
641 let d = default_spawn_directive(
642 "impl-lead",
643 "task-x",
644 "mse-worker-coder",
645 &view_with(None, None, None),
646 None,
647 None,
648 None,
649 );
650 assert!(!d.contains("project_name_alias:"));
651 assert!(!d.contains("LDS Session Alias"));
652 assert!(!d.contains("session_create"));
653 }
654
655 #[test]
656 fn directive_emits_project_name_alias_when_some() {
657 let d = default_spawn_directive(
658 "impl-lead",
659 "task-x",
660 "mse-worker-coder",
661 &view_with(Some("mse-task-7785"), None, None),
662 None,
663 None,
664 None,
665 );
666 assert!(
668 d.contains("project_name_alias: mse-task-7785"),
669 "directive missing project_name_alias header: {d}"
670 );
671 assert!(
673 d.contains("mcp__lds__session_create(root=<working_dir>, alias=\"mse-task-7785\")"),
674 "directive missing session_create reminder: {d}"
675 );
676 assert!(
677 d.contains("LDS Session Alias: mse-task-7785"),
678 "directive missing SubAgent prompt inject line: {d}"
679 );
680 assert!(
682 d.contains("inlined above") || d.contains("complete contract"),
683 "directive should inline rationale rather than point at external doc: {d}"
684 );
685 let forbidden_doc_ref = format!(".{}/CLAUDE.md", "claude");
694 assert!(
695 !d.contains(&forbidden_doc_ref),
696 "directive must not reference {forbidden_doc_ref} (out of MainAI scope): {d}"
697 );
698 }
699
700 #[test]
701 fn directive_omits_data_endpoint_when_none() {
702 let d = default_spawn_directive(
703 "impl-lead",
704 "task-x",
705 "mse-worker-coder",
706 &view_with(None, None, None),
707 None,
708 None,
709 None,
710 );
711 assert!(!d.contains("[Data path endpoint"));
712 assert!(!d.contains("DATA_EMIT"));
713 assert!(!d.contains("DATA_GET"));
714 }
715
716 #[test]
717 fn directive_emits_data_endpoint_when_some() {
718 let base = "http://127.0.0.1:7785";
719 let d = default_spawn_directive(
720 "impl-lead",
721 "task-x",
722 "mse-worker-coder",
723 &view_with(None, None, None),
724 Some(base),
725 None,
726 None,
727 );
728 assert!(
729 d.contains("[Data path endpoint"),
730 "directive missing data endpoint block header: {d}"
731 );
732 assert!(
733 d.contains(&format!("DATA_EMIT: {base}/v1/data/emit")),
734 "directive missing single-mouth emit line: {d}"
735 );
736 assert!(
737 d.contains("Bearer worker_handle or ?token="),
738 "directive missing auth transport hint: {d}"
739 );
740 assert!(
741 d.contains(&format!("DATA_GET: {base}/v1/data/<out_id|out_name>")),
742 "directive missing GET line: {d}"
743 );
744 assert!(
745 !d.contains("emit-auth"),
746 "old split endpoint must not leak into directive: {d}"
747 );
748 assert!(
749 d.contains("bypassing the MainAgent") && d.contains("out_id ref"),
750 "directive should carry the ownership + bypass reasoning: {d}"
751 );
752 }
753
754 #[test]
755 fn directive_carries_declared_subagent_type_and_has_no_fallback() {
756 let d = default_spawn_directive(
757 "impl-lead",
758 "task-x",
759 "mse-worker-coder",
760 &view_with(None, None, None),
761 None,
762 None,
763 None,
764 );
765 assert!(
766 d.contains("subagent_type=\"mse-worker-coder\""),
767 "directive must carry the Blueprint-declared subagent_type literally: {d}"
768 );
769 assert!(
770 d.contains(".claude/agents/mse-worker-coder.md"),
771 "directive must reference the declared subagent's own .md path: {d}"
772 );
773 assert!(
775 !d.contains("general-purpose"),
776 "directive must not fall back to subagent_type=\"general-purpose\": {d}"
777 );
778 assert!(
779 !d.contains("mse-worker\""),
780 "directive must not carry the old hardcoded \"mse-worker\" literal: {d}"
781 );
782 assert!(
783 d.contains("FAIL LOUD"),
784 "directive must instruct the MainAI to fail loud instead of falling back: {d}"
785 );
786 }
787
788 #[test]
794 fn directive_renders_actual_base_url_when_some() {
795 let d = default_spawn_directive(
796 "impl-lead",
797 "task-x",
798 "mse-worker-coder",
799 &view_with(None, None, None),
800 None,
801 Some("http://127.0.0.1:8888"),
802 None,
803 );
804 assert!(
805 d.contains("base_url: http://127.0.0.1:8888"),
806 "directive must render the actual bind literally: {d}"
807 );
808 assert!(
809 !d.contains("mse_doctor"),
810 "no mse_doctor detour when bind is known: {d}"
811 );
812 }
813
814 #[test]
818 fn directive_falls_back_to_mse_doctor_pointer_when_none() {
819 let d = default_spawn_directive(
820 "impl-lead",
821 "task-x",
822 "mse-worker-coder",
823 &view_with(None, None, None),
824 None,
825 None,
826 None,
827 );
828 assert!(
829 d.contains("check with mse_doctor"),
830 "fallback must point at mse_doctor: {d}"
831 );
832 }
833
834 #[test]
838 fn directive_never_contains_stale_example_port_7786() {
839 for base in [
840 None,
841 Some("http://127.0.0.1:7777"),
842 Some("http://192.0.2.1:9000"),
843 ] {
844 let d = default_spawn_directive(
845 "impl-lead",
846 "task-x",
847 "mse-worker-coder",
848 &view_with(Some("mse-task-alias"), None, None),
849 Some("http://127.0.0.1:7785"),
850 base,
851 None,
852 );
853 assert!(
854 !d.contains("7786"),
855 "stale example port 7786 leaked: base={base:?}, d={d}"
856 );
857 }
858 }
859
860 #[test]
866 fn directive_never_contains_stale_tasks_id_route() {
867 let d = default_spawn_directive(
868 "impl-lead",
869 "task-x",
870 "mse-worker-coder",
871 &view_with(None, None, None),
872 None,
873 None,
874 Some("R-abc123"),
875 );
876 assert!(
877 !d.contains("/v1/tasks/{id}") && !d.contains("/v1/tasks/{{id}}"),
878 "stale /v1/tasks/{{id}} observation hint leaked: {d}"
879 );
880 }
881
882 #[test]
885 fn directive_renders_actual_run_id_when_some() {
886 let d = default_spawn_directive(
887 "impl-lead",
888 "task-x",
889 "mse-worker-coder",
890 &view_with(None, None, None),
891 None,
892 None,
893 Some("R-abc123"),
894 );
895 assert!(
896 d.contains("GET <base_url>/v1/runs/R-abc123"),
897 "directive missing real run_id in observation route: {d}"
898 );
899 }
900
901 #[test]
904 fn directive_falls_back_to_run_id_placeholder_when_none() {
905 let d = default_spawn_directive(
906 "impl-lead",
907 "task-x",
908 "mse-worker-coder",
909 &view_with(None, None, None),
910 None,
911 None,
912 None,
913 );
914 assert!(
915 d.contains("GET <base_url>/v1/runs/<run_id>"),
916 "directive missing placeholder observation route: {d}"
917 );
918 }
919
920 #[test]
925 fn directive_omits_project_root_and_work_dir_when_both_none() {
926 let d = default_spawn_directive(
927 "impl-lead",
928 "task-x",
929 "mse-worker-coder",
930 &view_with(None, None, None),
931 None,
932 None,
933 None,
934 );
935 assert!(!d.contains("project_root:"));
936 assert!(!d.contains("work_dir:"));
937 }
938
939 #[test]
942 fn directive_splices_project_root_and_work_dir_when_both_present() {
943 let d = default_spawn_directive(
944 "impl-lead",
945 "task-x",
946 "mse-worker-coder",
947 &view_with(None, Some("/repo"), Some("/repo/work")),
948 None,
949 None,
950 None,
951 );
952 assert!(
953 d.contains("project_root: /repo"),
954 "directive missing project_root header: {d}"
955 );
956 assert!(
957 d.contains("work_dir: /repo/work"),
958 "directive missing work_dir header: {d}"
959 );
960 }
961
962 #[test]
965 fn directive_splices_project_root_only_when_work_dir_absent() {
966 let d = default_spawn_directive(
967 "impl-lead",
968 "task-x",
969 "mse-worker-coder",
970 &view_with(None, Some("/repo"), None),
971 None,
972 None,
973 None,
974 );
975 assert!(
976 d.contains("project_root: /repo"),
977 "directive missing project_root header: {d}"
978 );
979 assert!(!d.contains("work_dir:"));
980 }
981
982 #[test]
989 fn directive_splices_task_metadata_when_some() {
990 let view = AgentContextView {
991 task_metadata: Some(serde_json::json!({"issue": 20})),
992 ..view_with(None, Some("/repo"), None)
993 };
994 let d = default_spawn_directive(
995 "impl-lead",
996 "task-x",
997 "mse-worker-coder",
998 &view,
999 None,
1000 None,
1001 None,
1002 );
1003 assert!(
1004 d.contains(r#"task_metadata: {"issue":20}"#),
1005 "directive missing task_metadata header: {d}"
1006 );
1007 assert!(d.contains("project_root: /repo"));
1009 }
1010
1011 #[test]
1015 fn directive_omits_task_metadata_when_none() {
1016 let d = default_spawn_directive(
1017 "impl-lead",
1018 "task-x",
1019 "mse-worker-coder",
1020 &view_with(None, None, None),
1021 None,
1022 None,
1023 None,
1024 );
1025 assert!(!d.contains("task_metadata:"));
1026 }
1027
1028 fn test_ctx(task_id: &str) -> mlua_swarm::Ctx {
1031 mlua_swarm::Ctx::new(mlua_swarm::StepId::parse(task_id).unwrap(), 1, "a")
1032 }
1033
1034 fn test_worker_binding() -> mlua_swarm::WorkerBinding {
1035 mlua_swarm::WorkerBinding {
1036 variant: "test-variant".into(),
1037 tools: vec![],
1038 }
1039 }
1040
1041 fn test_cap_token() -> mlua_swarm::CapToken {
1042 mlua_swarm::CapToken {
1043 agent_id: "a".into(),
1044 role: mlua_swarm::Role::Worker,
1045 scopes: vec!["*".into()],
1046 issued_at: 0,
1047 expire_at: u64::MAX / 2,
1048 max_uses: None,
1049 nonce: "test-nonce".into(),
1050 sig_hex: "".into(),
1051 }
1052 }
1053
1054 #[tokio::test]
1060 async fn spawn_halt_reply_lands_as_ok_worker_result_with_marker() {
1061 use mlua_swarm::Operator;
1062 use tokio::sync::mpsc;
1063
1064 let (tx, mut rx) = mpsc::unbounded_channel();
1065 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1066 SessionId::parse("S-halt").unwrap(),
1067 tx,
1068 None,
1069 ));
1070
1071 let session_bg = session.clone();
1074 let handle = tokio::spawn(async move {
1075 session_bg
1076 .execute(
1077 &test_ctx("ST-halt"),
1078 None,
1079 "".into(),
1080 Some(test_worker_binding()),
1081 test_cap_token(),
1082 )
1083 .await
1084 });
1085
1086 let sent = rx.recv().await.expect("Spawn sent");
1087 let req_id = match sent {
1088 ServerMsg::Spawn { req_id, .. } => req_id,
1089 other => panic!("expected Spawn, got {other:?}"),
1090 };
1091
1092 session
1093 .resolve_pending(
1094 &req_id,
1095 PendingReply::SpawnHalt {
1096 value: serde_json::json!({"partial": "abc"}),
1097 reason: Some("shape verified".into()),
1098 },
1099 )
1100 .await;
1101
1102 let result = handle.await.expect("join").expect("execute Ok");
1103 assert!(
1104 result.ok,
1105 "spawn_halt must land as ok=true (normal termination), got: {result:?}"
1106 );
1107 assert_eq!(result.value["halted"], true);
1108 assert_eq!(result.value["reason"], "shape verified");
1109 assert_eq!(result.value["value"], serde_json::json!({"partial": "abc"}));
1110 }
1111
1112 #[tokio::test]
1115 async fn spawn_ack_with_error_still_lands_as_worker_error() {
1116 use mlua_swarm::{Operator, WorkerError};
1117 use tokio::sync::mpsc;
1118
1119 let (tx, mut rx) = mpsc::unbounded_channel();
1120 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1121 SessionId::parse("S-err").unwrap(),
1122 tx,
1123 None,
1124 ));
1125
1126 let session_bg = session.clone();
1127 let handle = tokio::spawn(async move {
1128 session_bg
1129 .execute(
1130 &test_ctx("ST-err"),
1131 None,
1132 "".into(),
1133 Some(test_worker_binding()),
1134 test_cap_token(),
1135 )
1136 .await
1137 });
1138
1139 let sent = rx.recv().await.expect("Spawn sent");
1140 let req_id = match sent {
1141 ServerMsg::Spawn { req_id, .. } => req_id,
1142 other => panic!("expected Spawn, got {other:?}"),
1143 };
1144
1145 session
1146 .resolve_pending(
1147 &req_id,
1148 PendingReply::SpawnAck {
1149 value: serde_json::json!({}),
1150 ok: false,
1151 error: Some("real crash".into()),
1152 },
1153 )
1154 .await;
1155
1156 let err = handle.await.expect("join").expect_err("must be error");
1157 assert!(matches!(err, WorkerError::Failed(msg) if msg.contains("real crash")));
1158 }
1159
1160 #[tokio::test]
1167 async fn execute_splices_project_root_and_work_dir_from_ctx_meta_runtime() {
1168 use mlua_swarm::Operator;
1169 use tokio::sync::mpsc;
1170
1171 let (tx, mut rx) = mpsc::unbounded_channel();
1172 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1173 SessionId::parse("S-ctxroot").unwrap(),
1174 tx,
1175 None,
1176 ));
1177
1178 let mut ctx = test_ctx("ST-ctxroot");
1179 ctx.meta.runtime.insert(
1180 TASK_PROJECT_ROOT_KEY.to_string(),
1181 serde_json::json!("/repo"),
1182 );
1183 ctx.meta.runtime.insert(
1184 TASK_WORK_DIR_KEY.to_string(),
1185 serde_json::json!("/repo/work"),
1186 );
1187
1188 let session_bg = session.clone();
1189 let handle = tokio::spawn(async move {
1190 session_bg
1191 .execute(
1192 &ctx,
1193 None,
1194 "".into(),
1195 Some(test_worker_binding()),
1196 test_cap_token(),
1197 )
1198 .await
1199 });
1200
1201 let sent = rx.recv().await.expect("Spawn sent");
1202 let req_id = match sent {
1203 ServerMsg::Spawn {
1204 req_id, directive, ..
1205 } => {
1206 let directive = directive.as_str();
1210 assert!(
1211 directive.contains("project_root: /repo"),
1212 "directive missing project_root splice: {directive}"
1213 );
1214 assert!(
1215 directive.contains("work_dir: /repo/work"),
1216 "directive missing work_dir splice: {directive}"
1217 );
1218 req_id
1219 }
1220 other => panic!("expected Spawn, got {other:?}"),
1221 };
1222
1223 session
1224 .resolve_pending(
1225 &req_id,
1226 PendingReply::SpawnAck {
1227 value: serde_json::json!({}),
1228 ok: true,
1229 error: None,
1230 },
1231 )
1232 .await;
1233 handle.await.expect("join").expect("execute Ok");
1234 }
1235
1236 #[tokio::test]
1241 async fn execute_splices_project_root_only_when_ctx_meta_runtime_partial() {
1242 use mlua_swarm::Operator;
1243 use tokio::sync::mpsc;
1244
1245 let (tx, mut rx) = mpsc::unbounded_channel();
1246 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1247 SessionId::parse("S-ctxpartial").unwrap(),
1248 tx,
1249 None,
1250 ));
1251
1252 let mut ctx = test_ctx("ST-ctxpartial");
1253 ctx.meta.runtime.insert(
1254 TASK_PROJECT_ROOT_KEY.to_string(),
1255 serde_json::json!("/repo"),
1256 );
1257
1258 let session_bg = session.clone();
1259 let handle = tokio::spawn(async move {
1260 session_bg
1261 .execute(
1262 &ctx,
1263 None,
1264 "".into(),
1265 Some(test_worker_binding()),
1266 test_cap_token(),
1267 )
1268 .await
1269 });
1270
1271 let sent = rx.recv().await.expect("Spawn sent");
1272 let req_id = match sent {
1273 ServerMsg::Spawn {
1274 req_id, directive, ..
1275 } => {
1276 let directive = directive.as_str();
1277 assert!(
1278 directive.contains("project_root: /repo"),
1279 "directive missing project_root splice: {directive}"
1280 );
1281 assert!(!directive.contains("work_dir:"));
1282 req_id
1283 }
1284 other => panic!("expected Spawn, got {other:?}"),
1285 };
1286
1287 session
1288 .resolve_pending(
1289 &req_id,
1290 PendingReply::SpawnAck {
1291 value: serde_json::json!({}),
1292 ok: true,
1293 error: None,
1294 },
1295 )
1296 .await;
1297 handle.await.expect("join").expect("execute Ok");
1298 }
1299
1300 #[tokio::test]
1304 async fn execute_omits_project_root_and_work_dir_when_ctx_meta_runtime_absent() {
1305 use mlua_swarm::Operator;
1306 use tokio::sync::mpsc;
1307
1308 let (tx, mut rx) = mpsc::unbounded_channel();
1309 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1310 SessionId::parse("S-ctxabsent").unwrap(),
1311 tx,
1312 None,
1313 ));
1314
1315 let ctx = test_ctx("ST-ctxabsent");
1316
1317 let session_bg = session.clone();
1318 let handle = tokio::spawn(async move {
1319 session_bg
1320 .execute(
1321 &ctx,
1322 None,
1323 "".into(),
1324 Some(test_worker_binding()),
1325 test_cap_token(),
1326 )
1327 .await
1328 });
1329
1330 let sent = rx.recv().await.expect("Spawn sent");
1331 let req_id = match sent {
1332 ServerMsg::Spawn {
1333 req_id, directive, ..
1334 } => {
1335 let directive = directive.as_str();
1336 assert!(!directive.contains("project_root:"));
1337 assert!(!directive.contains("work_dir:"));
1338 req_id
1339 }
1340 other => panic!("expected Spawn, got {other:?}"),
1341 };
1342
1343 session
1344 .resolve_pending(
1345 &req_id,
1346 PendingReply::SpawnAck {
1347 value: serde_json::json!({}),
1348 ok: true,
1349 error: None,
1350 },
1351 )
1352 .await;
1353 handle.await.expect("join").expect("execute Ok");
1354 }
1355
1356 #[tokio::test]
1362 async fn execute_splices_task_metadata_from_ctx_meta_runtime() {
1363 use mlua_swarm::Operator;
1364 use tokio::sync::mpsc;
1365
1366 let (tx, mut rx) = mpsc::unbounded_channel();
1367 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1368 SessionId::parse("S-ctxmeta").unwrap(),
1369 tx,
1370 None,
1371 ));
1372
1373 let mut ctx = test_ctx("ST-ctxmeta");
1374 ctx.meta.runtime.insert(
1375 TASK_METADATA_KEY.to_string(),
1376 serde_json::json!({"issue": 20}),
1377 );
1378
1379 let session_bg = session.clone();
1380 let handle = tokio::spawn(async move {
1381 session_bg
1382 .execute(
1383 &ctx,
1384 None,
1385 "".into(),
1386 Some(test_worker_binding()),
1387 test_cap_token(),
1388 )
1389 .await
1390 });
1391
1392 let sent = rx.recv().await.expect("Spawn sent");
1393 let req_id = match sent {
1394 ServerMsg::Spawn {
1395 req_id, directive, ..
1396 } => {
1397 let directive = directive.as_str();
1398 assert!(
1399 directive.contains(r#"task_metadata: {"issue":20}"#),
1400 "directive missing task_metadata splice: {directive}"
1401 );
1402 req_id
1403 }
1404 other => panic!("expected Spawn, got {other:?}"),
1405 };
1406
1407 session
1408 .resolve_pending(
1409 &req_id,
1410 PendingReply::SpawnAck {
1411 value: serde_json::json!({}),
1412 ok: true,
1413 error: None,
1414 },
1415 )
1416 .await;
1417 handle.await.expect("join").expect("execute Ok");
1418 }
1419
1420 #[test]
1426 fn with_task_directive_splices_string_seed_verbatim() {
1427 let directive = default_spawn_directive_with_task_directive(
1428 "impl-lead",
1429 "task-x",
1430 "mse-worker-coder",
1431 &view_with(None, None, None),
1432 None,
1433 None,
1434 None,
1435 &serde_json::json!("do the thing"),
1436 );
1437 let text = directive.as_str();
1438 assert!(
1439 text.contains("task_directive: do the thing"),
1440 "missing task_directive line for a String seed: {text}"
1441 );
1442 }
1443
1444 #[test]
1448 fn with_task_directive_renders_object_seed_as_json_literal() {
1449 let directive = default_spawn_directive_with_task_directive(
1450 "impl-lead",
1451 "task-x",
1452 "mse-worker-coder",
1453 &view_with(None, None, None),
1454 None,
1455 None,
1456 None,
1457 &serde_json::json!({"key": "value"}),
1458 );
1459 let text = directive.as_str();
1460 assert!(
1461 text.contains(r#"task_directive: {"key":"value"}"#),
1462 "missing JSON-literal task_directive line for an Object seed: {text}"
1463 );
1464 }
1465
1466 #[test]
1470 fn with_task_directive_omits_line_when_null() {
1471 let wrapped = default_spawn_directive_with_task_directive(
1472 "impl-lead",
1473 "task-x",
1474 "mse-worker-coder",
1475 &view_with(None, None, None),
1476 None,
1477 None,
1478 None,
1479 &serde_json::Value::Null,
1480 );
1481 let plain = default_spawn_directive(
1482 "impl-lead",
1483 "task-x",
1484 "mse-worker-coder",
1485 &view_with(None, None, None),
1486 None,
1487 None,
1488 None,
1489 );
1490 assert_eq!(
1491 wrapped,
1492 serde_json::Value::String(plain),
1493 "Value::Null seed must not add a task_directive line"
1494 );
1495 }
1496
1497 #[tokio::test]
1504 async fn execute_splices_json_literal_task_directive_for_object_seed() {
1505 use mlua_swarm::Operator;
1506 use tokio::sync::mpsc;
1507
1508 let (tx, mut rx) = mpsc::unbounded_channel();
1509 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1510 SessionId::parse("S-objseed").unwrap(),
1511 tx,
1512 None,
1513 ));
1514
1515 let ctx = test_ctx("ST-objseed");
1516 let rendered_prompt = serde_json::json!({"key": "value"});
1521
1522 let session_bg = session.clone();
1523 let handle = tokio::spawn(async move {
1524 session_bg
1525 .execute(
1526 &ctx,
1527 None,
1528 rendered_prompt,
1529 Some(test_worker_binding()),
1530 test_cap_token(),
1531 )
1532 .await
1533 });
1534
1535 let sent = rx.recv().await.expect("Spawn sent");
1536 let req_id = match sent {
1537 ServerMsg::Spawn {
1538 req_id, directive, ..
1539 } => {
1540 let directive = directive.as_str();
1541 assert!(
1542 directive.contains(r#"task_directive: {"key":"value"}"#),
1543 "directive missing JSON-literal task_directive splice: {directive}"
1544 );
1545 req_id
1546 }
1547 other => panic!("expected Spawn, got {other:?}"),
1548 };
1549
1550 session
1551 .resolve_pending(
1552 &req_id,
1553 PendingReply::SpawnAck {
1554 value: serde_json::json!({}),
1555 ok: true,
1556 error: None,
1557 },
1558 )
1559 .await;
1560 handle.await.expect("join").expect("execute Ok");
1561 }
1562
1563 #[tokio::test]
1569 async fn execute_with_work_dir_appends_ctx_projection_pointer_and_materializes_file() {
1570 use mlua_swarm::Operator;
1571 use tokio::sync::mpsc;
1572
1573 let dir = tempfile::TempDir::new().unwrap();
1574 let mut ctx = test_ctx("ST-proj-1");
1575 ctx.meta.runtime.insert(
1576 TASK_WORK_DIR_KEY.to_string(),
1577 Value::String(dir.path().to_string_lossy().into_owned()),
1578 );
1579
1580 let (tx, mut rx) = mpsc::unbounded_channel();
1581 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1582 SessionId::parse("S-proj-1").unwrap(),
1583 tx,
1584 None,
1585 ));
1586
1587 let session_bg = session.clone();
1588 let handle = tokio::spawn(async move {
1589 session_bg
1590 .execute(
1591 &ctx,
1592 None,
1593 "".into(),
1594 Some(test_worker_binding()),
1595 test_cap_token(),
1596 )
1597 .await
1598 });
1599
1600 let sent = rx.recv().await.expect("Spawn sent");
1601 let req_id = match sent {
1602 ServerMsg::Spawn {
1603 req_id, directive, ..
1604 } => {
1605 assert!(
1606 directive.contains("ctx_projection:"),
1607 "directive missing ctx_projection pointer line: {directive}"
1608 );
1609 assert!(
1616 !directive.contains("ctx_step_dir:"),
1617 "directive must not carry the retired ctx_step_dir line: {directive}"
1618 );
1619 req_id
1620 }
1621 other => panic!("expected Spawn, got {other:?}"),
1622 };
1623
1624 session
1625 .resolve_pending(
1626 &req_id,
1627 PendingReply::SpawnAck {
1628 value: serde_json::json!({}),
1629 ok: true,
1630 error: None,
1631 },
1632 )
1633 .await;
1634 handle.await.expect("join").expect("execute Ok");
1635
1636 let expected_file = dir.path().join("workspace/tasks/ST-proj-1/ctx/_ctx.md");
1637 assert!(
1638 expected_file.exists(),
1639 "materialized projection file missing at {expected_file:?}"
1640 );
1641 }
1642
1643 #[tokio::test]
1648 async fn execute_without_work_dir_spawns_without_ctx_projection_pointer() {
1649 use mlua_swarm::Operator;
1650 use tokio::sync::mpsc;
1651
1652 let (tx, mut rx) = mpsc::unbounded_channel();
1653 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1654 SessionId::parse("S-proj-2").unwrap(),
1655 tx,
1656 None,
1657 ));
1658
1659 let session_bg = session.clone();
1660 let handle = tokio::spawn(async move {
1661 session_bg
1662 .execute(
1663 &test_ctx("ST-proj-2"),
1664 None,
1665 "".into(),
1666 Some(test_worker_binding()),
1667 test_cap_token(),
1668 )
1669 .await
1670 });
1671
1672 let sent = rx.recv().await.expect("Spawn sent");
1673 let req_id = match sent {
1674 ServerMsg::Spawn {
1675 req_id, directive, ..
1676 } => {
1677 assert!(
1678 !directive.contains("ctx_projection:"),
1679 "directive must not carry a pointer line when work_dir is absent \
1680 (fallback): {directive}"
1681 );
1682 req_id
1683 }
1684 other => panic!("expected Spawn, got {other:?}"),
1685 };
1686
1687 session
1688 .resolve_pending(
1689 &req_id,
1690 PendingReply::SpawnAck {
1691 value: serde_json::json!({}),
1692 ok: true,
1693 error: None,
1694 },
1695 )
1696 .await;
1697 handle
1698 .await
1699 .expect("join")
1700 .expect("execute Ok — a materialize skip must not fail the spawn");
1701 }
1702}