1use async_trait::async_trait;
14use mlua_swarm::core::agent_context::AgentContextView;
15use mlua_swarm::{
16 CapToken, Ctx, Operator, SeniorBridge, SessionId, SpawnHook, StepId, WorkerBinding,
17 WorkerError, WorkerResult,
18};
19use serde_json::Value;
20use std::collections::HashMap;
21use tokio::sync::{mpsc, oneshot, Mutex};
22
23use super::protocol::{current_parent_req_id, PendingReply, ServerMsg};
24
25pub struct WSOperatorSession {
27 sid: SessionId,
28 tx: Mutex<Option<mpsc::UnboundedSender<ServerMsg>>>,
31 pending: Mutex<HashMap<String, oneshot::Sender<PendingReply>>>,
34 base_url: Option<std::sync::Arc<str>>,
40}
41
42impl WSOperatorSession {
43 pub(super) fn new_with_base_url(
53 sid: SessionId,
54 tx: mpsc::UnboundedSender<ServerMsg>,
55 base_url: Option<std::sync::Arc<str>>,
56 ) -> Self {
57 Self {
58 sid,
59 tx: Mutex::new(Some(tx)),
60 pending: Mutex::new(HashMap::new()),
61 base_url,
62 }
63 }
64
65 pub(super) async fn replace_tx(&self, new_tx: mpsc::UnboundedSender<ServerMsg>) {
67 *self.tx.lock().await = Some(new_tx);
68 }
69
70 pub(crate) async fn clear_tx(&self) {
72 *self.tx.lock().await = None;
73 }
74
75 pub(super) async fn resolve_pending(&self, req_id: &str, reply: PendingReply) {
78 if let Some(otx) = self.pending.lock().await.remove(req_id) {
79 let _ = otx.send(reply);
80 }
81 }
82
83 async fn send_and_await(&self, req_id: String, msg: ServerMsg) -> Result<PendingReply, String> {
87 let (otx, orx) = oneshot::channel::<PendingReply>();
88 self.pending.lock().await.insert(req_id.clone(), otx);
89
90 let send_result = {
92 let guard = self.tx.lock().await;
93 match guard.as_ref() {
94 Some(tx) => tx
95 .send(msg)
96 .map_err(|_| "ws send channel closed".to_string()),
97 None => Err("ws operator disconnected".to_string()),
98 }
99 };
100 if let Err(e) = send_result {
101 self.pending.lock().await.remove(&req_id);
102 return Err(e);
103 }
104
105 orx.await
106 .map_err(|_| "ws operator: oneshot cancelled (= reply path closed)".to_string())
107 }
108
109 async fn send_oneway(&self, msg: ServerMsg) -> Result<(), String> {
111 let guard = self.tx.lock().await;
112 match guard.as_ref() {
113 Some(tx) => tx
114 .send(msg)
115 .map_err(|_| "ws send channel closed".to_string()),
116 None => Err("ws operator disconnected".to_string()),
117 }
118 }
119}
120
121#[async_trait]
122impl SeniorBridge for WSOperatorSession {
123 async fn ask(&self, task_id: &StepId, question: Value) -> Result<Value, String> {
124 let req_id = format!("{}-ask-{}", self.sid, uuid::Uuid::new_v4());
125 let msg = ServerMsg::Ask {
126 req_id: req_id.clone(),
127 parent_req_id: current_parent_req_id(),
128 task_id: task_id.clone(),
129 question,
130 };
131 match self.send_and_await(req_id, msg).await? {
132 PendingReply::Answer(v) => Ok(v),
133 PendingReply::HookAck { .. } => {
134 Err("ws operator: unexpected hook_ack reply to ask".into())
135 }
136 PendingReply::SpawnAck { .. } => {
137 Err("ws operator: unexpected spawn_ack reply to ask".into())
138 }
139 PendingReply::SpawnHalt { .. } => {
140 Err("ws operator: unexpected spawn_halt reply to ask".into())
141 }
142 }
143 }
144}
145
146#[async_trait]
147impl SpawnHook for WSOperatorSession {
148 async fn before(&self, ctx: &Ctx) -> Result<(), String> {
149 let req_id = format!("{}-hb-{}", self.sid, uuid::Uuid::new_v4());
150 let msg = ServerMsg::HookBefore {
151 req_id: req_id.clone(),
152 parent_req_id: current_parent_req_id(),
153 task_id: ctx.task_id.clone(),
154 agent: ctx.agent.clone(),
155 attempt: ctx.attempt,
156 };
157 match self.send_and_await(req_id, msg).await? {
158 PendingReply::HookAck { ok: true, .. } => Ok(()),
159 PendingReply::HookAck { ok: false, reason } => {
160 Err(reason.unwrap_or_else(|| "ws operator: spawn rejected".into()))
161 }
162 PendingReply::Answer(_) => {
163 Err("ws operator: unexpected answer reply to hook_before".into())
164 }
165 PendingReply::SpawnAck { .. } => {
166 Err("ws operator: unexpected spawn_ack reply to hook_before".into())
167 }
168 PendingReply::SpawnHalt { .. } => {
169 Err("ws operator: unexpected spawn_halt reply to hook_before".into())
170 }
171 }
172 }
173
174 async fn after(&self, ctx: &Ctx, result: &Value) -> Result<(), String> {
175 let req_id = format!("{}-ha-{}", self.sid, uuid::Uuid::new_v4());
176 let msg = ServerMsg::HookAfter {
177 req_id,
178 parent_req_id: current_parent_req_id(),
179 task_id: ctx.task_id.clone(),
180 agent: ctx.agent.clone(),
181 attempt: ctx.attempt,
182 result: result.clone(),
183 };
184 let _ = self.send_oneway(msg).await;
186 Ok(())
187 }
188}
189
190#[async_trait]
191impl Operator for WSOperatorSession {
192 async fn execute(
215 &self,
216 ctx: &Ctx,
217 _system: Option<String>,
218 prompt: Value,
219 worker: Option<WorkerBinding>,
220 worker_token: CapToken,
221 ) -> Result<WorkerResult, WorkerError> {
222 let Some(worker) = worker else {
223 return Err(WorkerError::Failed(format!(
224 "agent '{}' has no worker_binding; WS thin-path requires one \
225 (Blueprint AgentDef.profile.worker_binding)",
226 ctx.agent
227 )));
228 };
229 let req_id = format!("{}-spawn-{}", self.sid, uuid::Uuid::new_v4());
230 let worker_handle = ctx
231 .meta
232 .runtime
233 .get("worker_handle")
234 .and_then(|v| v.as_str())
235 .map(|s| s.to_string());
236 let data_sink_endpoint = ctx
237 .meta
238 .runtime
239 .get("data_sink_endpoint")
240 .and_then(|v| v.as_str());
241 let run_id = ctx.meta.runtime.get("run_id").and_then(|v| v.as_str());
246 let view = AgentContextView::materialized_or_from_ctx(ctx);
255 let directive = default_spawn_directive_with_task_directive(
262 &ctx.agent,
263 ctx.task_id.as_str(),
264 &worker.variant,
265 &view,
266 data_sink_endpoint,
267 self.base_url.as_deref(),
268 run_id,
269 &prompt,
270 );
271 let msg = ServerMsg::Spawn {
272 req_id: req_id.clone(),
273 parent_req_id: current_parent_req_id(),
274 task_id: ctx.task_id.clone(),
275 agent: ctx.agent.clone(),
276 attempt: ctx.attempt,
277 capability_token: worker_token.encode(),
278 worker_handle,
279 worker: Some(worker),
280 directive,
281 };
282 match self.send_and_await(req_id, msg).await {
283 Ok(PendingReply::SpawnAck {
284 value,
285 ok,
286 error: None,
287 }) => Ok(WorkerResult { value, ok }),
288 Ok(PendingReply::SpawnAck {
289 error: Some(msg), ..
290 }) => Err(WorkerError::Failed(msg)),
291 Ok(PendingReply::SpawnHalt { value, reason }) => {
298 let marker = serde_json::json!({
299 "halted": true,
300 "reason": reason,
301 "value": value,
302 });
303 Ok(WorkerResult {
304 value: marker,
305 ok: true,
306 })
307 }
308 Ok(_) => Err(WorkerError::Failed(
309 "ws operator: unexpected non-spawn reply".into(),
310 )),
311 Err(e) => Err(WorkerError::Failed(format!("ws operator spawn: {e}"))),
312 }
313 }
314
315 fn requires_worker_binding(&self) -> bool {
316 true
317 }
318}
319
320#[allow(clippy::too_many_arguments)]
378pub(super) fn default_spawn_directive(
379 agent: &str,
380 task_id: &str,
381 subagent_type: &str,
382 view: &AgentContextView,
383 data_sink_endpoint: Option<&str>,
384 base_url: Option<&str>,
385 run_id: Option<&str>,
386) -> String {
387 let context_header = view.to_directive_header();
391 let data_endpoint_block = match data_sink_endpoint {
398 Some(base) => format!(
399 "\n\
400 [Data path endpoint — MainAgent reminder]\n\
401 When you kick a SubAgent, inject the following two lines into\n\
402 its prompt / environment so Big Response payloads (4k+ tokens,\n\
403 files, intermediate artifacts) flow directly to the Store owner,\n\
404 bypassing the MainAgent (context stays small; only the out_id\n\
405 ref is passed around).\n \
406 DATA_EMIT: {base}/v1/data/emit (POST, auth = Bearer worker_handle or ?token=)\n \
407 DATA_GET: {base}/v1/data/<out_id|out_name> (the next SubAgent fetches from $IN_REFS)\n\
408 When a SubAgent produces a Big Response, POST it to DATA_EMIT\n\
409 and return only the one-line out_id ref (do not mix the body\n\
410 in; the MainAgent must not answer directly).\n\
411 \n"
412 ),
413 None => String::new(),
414 };
415 let main_ai_reminder = match view.project_name_alias.as_deref() {
416 Some(a) => format!(
417 "\n\
418 [LDS Session Alias Reminder — MainAI mandatory]\n\
419 Before kicking the SubAgent below, call:\n \
420 mcp__lds__session_create(root=<working_dir>, alias=\"{a}\")\n\
421 (= establish a single task-level lds session; reuse on repeated dispatch).\n\
422 Then add this literal line to the SubAgent prompt body below:\n \
423 LDS Session Alias: {a}\n\
424 The SubAgent will call mcp__lds__session_start(alias=\"{a}\") on init,\n\
425 keeping worktree ownership unified across dispatches.\n\
426 (Full discipline rationale is inlined above; reach is via this directive itself,\n\
427 not via any external doc path. The 2 steps above are the complete contract.)\n\
428 \n"
429 ),
430 None => String::new(),
431 };
432 let base_url_line = match base_url {
436 Some(u) => u.to_string(),
437 None => "<your server's actual bind — check with mse_doctor>".to_string(),
438 };
439 let run_route_line = match run_id {
444 Some(rid) => format!("GET <base_url>/v1/runs/{rid}"),
445 None => "GET <base_url>/v1/runs/<run_id>".to_string(),
446 };
447 format!(
448 "[agent_primitive dispatch=@{agent}]\n\
449 worker endpoint:\n \
450 GET <base_url>/v1/worker/prompt?task_id={task_id}\n \
451 POST <base_url>/v1/worker/submit\n\
452 auth: Bearer <worker_handle from THIS Spawn payload (= short `wh-XXXXXXXX` form)>\n\
453 task_id: {task_id}\n\
454 agent_id: {agent}\n\
455 {context_header}\
456 {data_endpoint_block}\
457 {main_ai_reminder}\
458 Kick a SubAgent via Agent tool with subagent_type=\"{subagent_type}\" (= project-local \
459 `.claude/agents/{subagent_type}.md`, this agent's Blueprint-declared worker binding). \
460 The prompt you pass to it MUST be EXACTLY these 4 lines (no preamble, no extra text):\n\
461 \n \
462 agent_id: {agent}\n \
463 worker_handle: <THIS Spawn payload's `worker_handle` field (short string `wh-XXXXXXXX`)>\n \
464 base_url: {base_url_line}\n \
465 task_id: {task_id}\n\
466 \n\
467 The SubAgent self-fetches system + prompt via GET (Bearer = handle), \
468 executes as agent @{agent}, POSTs raw body to /v1/worker/submit (Bearer = handle, \
469 server resolves task_id from handle), and replies `OUTPUT` 1 word. You then forward \
470 SpawnAck {{req_id, value:{{}}, ok:true}} through your operator client — MCP path: \
471 mse_ack(sid, req_id, kind=\"spawn_ack\", ok=true) (= empty value because canonical \
472 body lives in output_tail via the POST). \
473 Do NOT fetch /v1/worker/prompt yourself. Do NOT wrap, summarize, or field-select \
474 the SubAgent reply. Observation / debug is a separate channel (= agent-inspect MCP / \
475 {run_route_line}), do NOT mix it into the forward path. \
476 If the SubAgent type is not registered, FAIL LOUD: reply SpawnAck ok=false with an \
477 error explaining the missing `.claude/agents/{subagent_type}.md` — do NOT fall back \
478 to another subagent_type."
479 )
480}
481
482#[allow(clippy::too_many_arguments)]
496pub(super) fn default_spawn_directive_with_task_directive(
497 agent: &str,
498 task_id: &str,
499 subagent_type: &str,
500 view: &AgentContextView,
501 data_sink_endpoint: Option<&str>,
502 base_url: Option<&str>,
503 run_id: Option<&str>,
504 task_directive: &Value,
505) -> String {
506 let base = default_spawn_directive(
507 agent,
508 task_id,
509 subagent_type,
510 view,
511 data_sink_endpoint,
512 base_url,
513 run_id,
514 );
515 let task_directive_line = match task_directive {
520 Value::Null => String::new(),
521 Value::String(s) => format!("task_directive: {s}\n"),
522 other => format!("task_directive: {other}\n"),
523 };
524 format!("{base}{task_directive_line}")
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use mlua_swarm::core::agent_context::{
531 TASK_METADATA_KEY, TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY,
532 };
533
534 fn view_with(
541 alias: Option<&str>,
542 project_root: Option<&str>,
543 work_dir: Option<&str>,
544 ) -> AgentContextView {
545 AgentContextView {
546 project_name_alias: alias.map(String::from),
547 project_root: project_root.map(String::from),
548 work_dir: work_dir.map(String::from),
549 ..AgentContextView::default()
550 }
551 }
552
553 #[test]
554 fn directive_omits_project_name_alias_when_none() {
555 let d = default_spawn_directive(
556 "impl-lead",
557 "task-x",
558 "mse-worker-coder",
559 &view_with(None, None, None),
560 None,
561 None,
562 None,
563 );
564 assert!(!d.contains("project_name_alias:"));
565 assert!(!d.contains("LDS Session Alias"));
566 assert!(!d.contains("session_create"));
567 }
568
569 #[test]
570 fn directive_emits_project_name_alias_when_some() {
571 let d = default_spawn_directive(
572 "impl-lead",
573 "task-x",
574 "mse-worker-coder",
575 &view_with(Some("mse-task-7785"), None, None),
576 None,
577 None,
578 None,
579 );
580 assert!(
582 d.contains("project_name_alias: mse-task-7785"),
583 "directive missing project_name_alias header: {d}"
584 );
585 assert!(
587 d.contains("mcp__lds__session_create(root=<working_dir>, alias=\"mse-task-7785\")"),
588 "directive missing session_create reminder: {d}"
589 );
590 assert!(
591 d.contains("LDS Session Alias: mse-task-7785"),
592 "directive missing SubAgent prompt inject line: {d}"
593 );
594 assert!(
596 d.contains("inlined above") || d.contains("complete contract"),
597 "directive should inline rationale rather than point at external doc: {d}"
598 );
599 let forbidden_doc_ref = format!(".{}/CLAUDE.md", "claude");
608 assert!(
609 !d.contains(&forbidden_doc_ref),
610 "directive must not reference {forbidden_doc_ref} (out of MainAI scope): {d}"
611 );
612 }
613
614 #[test]
615 fn directive_omits_data_endpoint_when_none() {
616 let d = default_spawn_directive(
617 "impl-lead",
618 "task-x",
619 "mse-worker-coder",
620 &view_with(None, None, None),
621 None,
622 None,
623 None,
624 );
625 assert!(!d.contains("[Data path endpoint"));
626 assert!(!d.contains("DATA_EMIT"));
627 assert!(!d.contains("DATA_GET"));
628 }
629
630 #[test]
631 fn directive_emits_data_endpoint_when_some() {
632 let base = "http://127.0.0.1:7785";
633 let d = default_spawn_directive(
634 "impl-lead",
635 "task-x",
636 "mse-worker-coder",
637 &view_with(None, None, None),
638 Some(base),
639 None,
640 None,
641 );
642 assert!(
643 d.contains("[Data path endpoint"),
644 "directive missing data endpoint block header: {d}"
645 );
646 assert!(
647 d.contains(&format!("DATA_EMIT: {base}/v1/data/emit")),
648 "directive missing single-mouth emit line: {d}"
649 );
650 assert!(
651 d.contains("Bearer worker_handle or ?token="),
652 "directive missing auth transport hint: {d}"
653 );
654 assert!(
655 d.contains(&format!("DATA_GET: {base}/v1/data/<out_id|out_name>")),
656 "directive missing GET line: {d}"
657 );
658 assert!(
659 !d.contains("emit-auth"),
660 "old split endpoint must not leak into directive: {d}"
661 );
662 assert!(
663 d.contains("bypassing the MainAgent") && d.contains("out_id ref"),
664 "directive should carry the ownership + bypass reasoning: {d}"
665 );
666 }
667
668 #[test]
669 fn directive_carries_declared_subagent_type_and_has_no_fallback() {
670 let d = default_spawn_directive(
671 "impl-lead",
672 "task-x",
673 "mse-worker-coder",
674 &view_with(None, None, None),
675 None,
676 None,
677 None,
678 );
679 assert!(
680 d.contains("subagent_type=\"mse-worker-coder\""),
681 "directive must carry the Blueprint-declared subagent_type literally: {d}"
682 );
683 assert!(
684 d.contains(".claude/agents/mse-worker-coder.md"),
685 "directive must reference the declared subagent's own .md path: {d}"
686 );
687 assert!(
689 !d.contains("general-purpose"),
690 "directive must not fall back to subagent_type=\"general-purpose\": {d}"
691 );
692 assert!(
693 !d.contains("mse-worker\""),
694 "directive must not carry the old hardcoded \"mse-worker\" literal: {d}"
695 );
696 assert!(
697 d.contains("FAIL LOUD"),
698 "directive must instruct the MainAI to fail loud instead of falling back: {d}"
699 );
700 }
701
702 #[test]
708 fn directive_renders_actual_base_url_when_some() {
709 let d = default_spawn_directive(
710 "impl-lead",
711 "task-x",
712 "mse-worker-coder",
713 &view_with(None, None, None),
714 None,
715 Some("http://127.0.0.1:8888"),
716 None,
717 );
718 assert!(
719 d.contains("base_url: http://127.0.0.1:8888"),
720 "directive must render the actual bind literally: {d}"
721 );
722 assert!(
723 !d.contains("mse_doctor"),
724 "no mse_doctor detour when bind is known: {d}"
725 );
726 }
727
728 #[test]
732 fn directive_falls_back_to_mse_doctor_pointer_when_none() {
733 let d = default_spawn_directive(
734 "impl-lead",
735 "task-x",
736 "mse-worker-coder",
737 &view_with(None, None, None),
738 None,
739 None,
740 None,
741 );
742 assert!(
743 d.contains("check with mse_doctor"),
744 "fallback must point at mse_doctor: {d}"
745 );
746 }
747
748 #[test]
752 fn directive_never_contains_stale_example_port_7786() {
753 for base in [
754 None,
755 Some("http://127.0.0.1:7777"),
756 Some("http://192.0.2.1:9000"),
757 ] {
758 let d = default_spawn_directive(
759 "impl-lead",
760 "task-x",
761 "mse-worker-coder",
762 &view_with(Some("mse-task-alias"), None, None),
763 Some("http://127.0.0.1:7785"),
764 base,
765 None,
766 );
767 assert!(
768 !d.contains("7786"),
769 "stale example port 7786 leaked: base={base:?}, d={d}"
770 );
771 }
772 }
773
774 #[test]
780 fn directive_never_contains_stale_tasks_id_route() {
781 let d = default_spawn_directive(
782 "impl-lead",
783 "task-x",
784 "mse-worker-coder",
785 &view_with(None, None, None),
786 None,
787 None,
788 Some("R-abc123"),
789 );
790 assert!(
791 !d.contains("/v1/tasks/{id}") && !d.contains("/v1/tasks/{{id}}"),
792 "stale /v1/tasks/{{id}} observation hint leaked: {d}"
793 );
794 }
795
796 #[test]
799 fn directive_renders_actual_run_id_when_some() {
800 let d = default_spawn_directive(
801 "impl-lead",
802 "task-x",
803 "mse-worker-coder",
804 &view_with(None, None, None),
805 None,
806 None,
807 Some("R-abc123"),
808 );
809 assert!(
810 d.contains("GET <base_url>/v1/runs/R-abc123"),
811 "directive missing real run_id in observation route: {d}"
812 );
813 }
814
815 #[test]
818 fn directive_falls_back_to_run_id_placeholder_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("GET <base_url>/v1/runs/<run_id>"),
830 "directive missing placeholder observation route: {d}"
831 );
832 }
833
834 #[test]
839 fn directive_omits_project_root_and_work_dir_when_both_none() {
840 let d = default_spawn_directive(
841 "impl-lead",
842 "task-x",
843 "mse-worker-coder",
844 &view_with(None, None, None),
845 None,
846 None,
847 None,
848 );
849 assert!(!d.contains("project_root:"));
850 assert!(!d.contains("work_dir:"));
851 }
852
853 #[test]
856 fn directive_splices_project_root_and_work_dir_when_both_present() {
857 let d = default_spawn_directive(
858 "impl-lead",
859 "task-x",
860 "mse-worker-coder",
861 &view_with(None, Some("/repo"), Some("/repo/work")),
862 None,
863 None,
864 None,
865 );
866 assert!(
867 d.contains("project_root: /repo"),
868 "directive missing project_root header: {d}"
869 );
870 assert!(
871 d.contains("work_dir: /repo/work"),
872 "directive missing work_dir header: {d}"
873 );
874 }
875
876 #[test]
879 fn directive_splices_project_root_only_when_work_dir_absent() {
880 let d = default_spawn_directive(
881 "impl-lead",
882 "task-x",
883 "mse-worker-coder",
884 &view_with(None, Some("/repo"), None),
885 None,
886 None,
887 None,
888 );
889 assert!(
890 d.contains("project_root: /repo"),
891 "directive missing project_root header: {d}"
892 );
893 assert!(!d.contains("work_dir:"));
894 }
895
896 #[test]
903 fn directive_splices_task_metadata_when_some() {
904 let view = AgentContextView {
905 task_metadata: Some(serde_json::json!({"issue": 20})),
906 ..view_with(None, Some("/repo"), None)
907 };
908 let d = default_spawn_directive(
909 "impl-lead",
910 "task-x",
911 "mse-worker-coder",
912 &view,
913 None,
914 None,
915 None,
916 );
917 assert!(
918 d.contains(r#"task_metadata: {"issue":20}"#),
919 "directive missing task_metadata header: {d}"
920 );
921 assert!(d.contains("project_root: /repo"));
923 }
924
925 #[test]
929 fn directive_omits_task_metadata_when_none() {
930 let d = default_spawn_directive(
931 "impl-lead",
932 "task-x",
933 "mse-worker-coder",
934 &view_with(None, None, None),
935 None,
936 None,
937 None,
938 );
939 assert!(!d.contains("task_metadata:"));
940 }
941
942 fn test_ctx(task_id: &str) -> mlua_swarm::Ctx {
945 mlua_swarm::Ctx::new(mlua_swarm::StepId::parse(task_id).unwrap(), 1, "a")
946 }
947
948 fn test_worker_binding() -> mlua_swarm::WorkerBinding {
949 mlua_swarm::WorkerBinding {
950 variant: "test-variant".into(),
951 tools: vec![],
952 }
953 }
954
955 fn test_cap_token() -> mlua_swarm::CapToken {
956 mlua_swarm::CapToken {
957 agent_id: "a".into(),
958 role: mlua_swarm::Role::Worker,
959 scopes: vec!["*".into()],
960 issued_at: 0,
961 expire_at: u64::MAX / 2,
962 max_uses: None,
963 nonce: "test-nonce".into(),
964 sig_hex: "".into(),
965 }
966 }
967
968 #[tokio::test]
974 async fn spawn_halt_reply_lands_as_ok_worker_result_with_marker() {
975 use mlua_swarm::Operator;
976 use tokio::sync::mpsc;
977
978 let (tx, mut rx) = mpsc::unbounded_channel();
979 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
980 SessionId::parse("S-halt").unwrap(),
981 tx,
982 None,
983 ));
984
985 let session_bg = session.clone();
988 let handle = tokio::spawn(async move {
989 session_bg
990 .execute(
991 &test_ctx("ST-halt"),
992 None,
993 "".into(),
994 Some(test_worker_binding()),
995 test_cap_token(),
996 )
997 .await
998 });
999
1000 let sent = rx.recv().await.expect("Spawn sent");
1001 let req_id = match sent {
1002 ServerMsg::Spawn { req_id, .. } => req_id,
1003 other => panic!("expected Spawn, got {other:?}"),
1004 };
1005
1006 session
1007 .resolve_pending(
1008 &req_id,
1009 PendingReply::SpawnHalt {
1010 value: serde_json::json!({"partial": "abc"}),
1011 reason: Some("shape verified".into()),
1012 },
1013 )
1014 .await;
1015
1016 let result = handle.await.expect("join").expect("execute Ok");
1017 assert!(
1018 result.ok,
1019 "spawn_halt must land as ok=true (normal termination), got: {result:?}"
1020 );
1021 assert_eq!(result.value["halted"], true);
1022 assert_eq!(result.value["reason"], "shape verified");
1023 assert_eq!(result.value["value"], serde_json::json!({"partial": "abc"}));
1024 }
1025
1026 #[tokio::test]
1029 async fn spawn_ack_with_error_still_lands_as_worker_error() {
1030 use mlua_swarm::{Operator, WorkerError};
1031 use tokio::sync::mpsc;
1032
1033 let (tx, mut rx) = mpsc::unbounded_channel();
1034 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1035 SessionId::parse("S-err").unwrap(),
1036 tx,
1037 None,
1038 ));
1039
1040 let session_bg = session.clone();
1041 let handle = tokio::spawn(async move {
1042 session_bg
1043 .execute(
1044 &test_ctx("ST-err"),
1045 None,
1046 "".into(),
1047 Some(test_worker_binding()),
1048 test_cap_token(),
1049 )
1050 .await
1051 });
1052
1053 let sent = rx.recv().await.expect("Spawn sent");
1054 let req_id = match sent {
1055 ServerMsg::Spawn { req_id, .. } => req_id,
1056 other => panic!("expected Spawn, got {other:?}"),
1057 };
1058
1059 session
1060 .resolve_pending(
1061 &req_id,
1062 PendingReply::SpawnAck {
1063 value: serde_json::json!({}),
1064 ok: false,
1065 error: Some("real crash".into()),
1066 },
1067 )
1068 .await;
1069
1070 let err = handle.await.expect("join").expect_err("must be error");
1071 assert!(matches!(err, WorkerError::Failed(msg) if msg.contains("real crash")));
1072 }
1073
1074 #[tokio::test]
1081 async fn execute_splices_project_root_and_work_dir_from_ctx_meta_runtime() {
1082 use mlua_swarm::Operator;
1083 use tokio::sync::mpsc;
1084
1085 let (tx, mut rx) = mpsc::unbounded_channel();
1086 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1087 SessionId::parse("S-ctxroot").unwrap(),
1088 tx,
1089 None,
1090 ));
1091
1092 let mut ctx = test_ctx("ST-ctxroot");
1093 ctx.meta.runtime.insert(
1094 TASK_PROJECT_ROOT_KEY.to_string(),
1095 serde_json::json!("/repo"),
1096 );
1097 ctx.meta.runtime.insert(
1098 TASK_WORK_DIR_KEY.to_string(),
1099 serde_json::json!("/repo/work"),
1100 );
1101
1102 let session_bg = session.clone();
1103 let handle = tokio::spawn(async move {
1104 session_bg
1105 .execute(
1106 &ctx,
1107 None,
1108 "".into(),
1109 Some(test_worker_binding()),
1110 test_cap_token(),
1111 )
1112 .await
1113 });
1114
1115 let sent = rx.recv().await.expect("Spawn sent");
1116 let req_id = match sent {
1117 ServerMsg::Spawn {
1118 req_id, directive, ..
1119 } => {
1120 let directive = directive.as_str();
1124 assert!(
1125 directive.contains("project_root: /repo"),
1126 "directive missing project_root splice: {directive}"
1127 );
1128 assert!(
1129 directive.contains("work_dir: /repo/work"),
1130 "directive missing work_dir splice: {directive}"
1131 );
1132 req_id
1133 }
1134 other => panic!("expected Spawn, got {other:?}"),
1135 };
1136
1137 session
1138 .resolve_pending(
1139 &req_id,
1140 PendingReply::SpawnAck {
1141 value: serde_json::json!({}),
1142 ok: true,
1143 error: None,
1144 },
1145 )
1146 .await;
1147 handle.await.expect("join").expect("execute Ok");
1148 }
1149
1150 #[tokio::test]
1155 async fn execute_splices_project_root_only_when_ctx_meta_runtime_partial() {
1156 use mlua_swarm::Operator;
1157 use tokio::sync::mpsc;
1158
1159 let (tx, mut rx) = mpsc::unbounded_channel();
1160 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1161 SessionId::parse("S-ctxpartial").unwrap(),
1162 tx,
1163 None,
1164 ));
1165
1166 let mut ctx = test_ctx("ST-ctxpartial");
1167 ctx.meta.runtime.insert(
1168 TASK_PROJECT_ROOT_KEY.to_string(),
1169 serde_json::json!("/repo"),
1170 );
1171
1172 let session_bg = session.clone();
1173 let handle = tokio::spawn(async move {
1174 session_bg
1175 .execute(
1176 &ctx,
1177 None,
1178 "".into(),
1179 Some(test_worker_binding()),
1180 test_cap_token(),
1181 )
1182 .await
1183 });
1184
1185 let sent = rx.recv().await.expect("Spawn sent");
1186 let req_id = match sent {
1187 ServerMsg::Spawn {
1188 req_id, directive, ..
1189 } => {
1190 let directive = directive.as_str();
1191 assert!(
1192 directive.contains("project_root: /repo"),
1193 "directive missing project_root splice: {directive}"
1194 );
1195 assert!(!directive.contains("work_dir:"));
1196 req_id
1197 }
1198 other => panic!("expected Spawn, got {other:?}"),
1199 };
1200
1201 session
1202 .resolve_pending(
1203 &req_id,
1204 PendingReply::SpawnAck {
1205 value: serde_json::json!({}),
1206 ok: true,
1207 error: None,
1208 },
1209 )
1210 .await;
1211 handle.await.expect("join").expect("execute Ok");
1212 }
1213
1214 #[tokio::test]
1218 async fn execute_omits_project_root_and_work_dir_when_ctx_meta_runtime_absent() {
1219 use mlua_swarm::Operator;
1220 use tokio::sync::mpsc;
1221
1222 let (tx, mut rx) = mpsc::unbounded_channel();
1223 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1224 SessionId::parse("S-ctxabsent").unwrap(),
1225 tx,
1226 None,
1227 ));
1228
1229 let ctx = test_ctx("ST-ctxabsent");
1230
1231 let session_bg = session.clone();
1232 let handle = tokio::spawn(async move {
1233 session_bg
1234 .execute(
1235 &ctx,
1236 None,
1237 "".into(),
1238 Some(test_worker_binding()),
1239 test_cap_token(),
1240 )
1241 .await
1242 });
1243
1244 let sent = rx.recv().await.expect("Spawn sent");
1245 let req_id = match sent {
1246 ServerMsg::Spawn {
1247 req_id, directive, ..
1248 } => {
1249 let directive = directive.as_str();
1250 assert!(!directive.contains("project_root:"));
1251 assert!(!directive.contains("work_dir:"));
1252 req_id
1253 }
1254 other => panic!("expected Spawn, got {other:?}"),
1255 };
1256
1257 session
1258 .resolve_pending(
1259 &req_id,
1260 PendingReply::SpawnAck {
1261 value: serde_json::json!({}),
1262 ok: true,
1263 error: None,
1264 },
1265 )
1266 .await;
1267 handle.await.expect("join").expect("execute Ok");
1268 }
1269
1270 #[tokio::test]
1276 async fn execute_splices_task_metadata_from_ctx_meta_runtime() {
1277 use mlua_swarm::Operator;
1278 use tokio::sync::mpsc;
1279
1280 let (tx, mut rx) = mpsc::unbounded_channel();
1281 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1282 SessionId::parse("S-ctxmeta").unwrap(),
1283 tx,
1284 None,
1285 ));
1286
1287 let mut ctx = test_ctx("ST-ctxmeta");
1288 ctx.meta.runtime.insert(
1289 TASK_METADATA_KEY.to_string(),
1290 serde_json::json!({"issue": 20}),
1291 );
1292
1293 let session_bg = session.clone();
1294 let handle = tokio::spawn(async move {
1295 session_bg
1296 .execute(
1297 &ctx,
1298 None,
1299 "".into(),
1300 Some(test_worker_binding()),
1301 test_cap_token(),
1302 )
1303 .await
1304 });
1305
1306 let sent = rx.recv().await.expect("Spawn sent");
1307 let req_id = match sent {
1308 ServerMsg::Spawn {
1309 req_id, directive, ..
1310 } => {
1311 let directive = directive.as_str();
1312 assert!(
1313 directive.contains(r#"task_metadata: {"issue":20}"#),
1314 "directive missing task_metadata splice: {directive}"
1315 );
1316 req_id
1317 }
1318 other => panic!("expected Spawn, got {other:?}"),
1319 };
1320
1321 session
1322 .resolve_pending(
1323 &req_id,
1324 PendingReply::SpawnAck {
1325 value: serde_json::json!({}),
1326 ok: true,
1327 error: None,
1328 },
1329 )
1330 .await;
1331 handle.await.expect("join").expect("execute Ok");
1332 }
1333
1334 #[test]
1340 fn with_task_directive_splices_string_seed_verbatim() {
1341 let directive = default_spawn_directive_with_task_directive(
1342 "impl-lead",
1343 "task-x",
1344 "mse-worker-coder",
1345 &view_with(None, None, None),
1346 None,
1347 None,
1348 None,
1349 &serde_json::json!("do the thing"),
1350 );
1351 let text = directive.as_str();
1352 assert!(
1353 text.contains("task_directive: do the thing"),
1354 "missing task_directive line for a String seed: {text}"
1355 );
1356 }
1357
1358 #[test]
1362 fn with_task_directive_renders_object_seed_as_json_literal() {
1363 let directive = default_spawn_directive_with_task_directive(
1364 "impl-lead",
1365 "task-x",
1366 "mse-worker-coder",
1367 &view_with(None, None, None),
1368 None,
1369 None,
1370 None,
1371 &serde_json::json!({"key": "value"}),
1372 );
1373 let text = directive.as_str();
1374 assert!(
1375 text.contains(r#"task_directive: {"key":"value"}"#),
1376 "missing JSON-literal task_directive line for an Object seed: {text}"
1377 );
1378 }
1379
1380 #[test]
1384 fn with_task_directive_omits_line_when_null() {
1385 let wrapped = default_spawn_directive_with_task_directive(
1386 "impl-lead",
1387 "task-x",
1388 "mse-worker-coder",
1389 &view_with(None, None, None),
1390 None,
1391 None,
1392 None,
1393 &serde_json::Value::Null,
1394 );
1395 let plain = default_spawn_directive(
1396 "impl-lead",
1397 "task-x",
1398 "mse-worker-coder",
1399 &view_with(None, None, None),
1400 None,
1401 None,
1402 None,
1403 );
1404 assert_eq!(
1405 wrapped,
1406 serde_json::Value::String(plain),
1407 "Value::Null seed must not add a task_directive line"
1408 );
1409 }
1410
1411 #[tokio::test]
1418 async fn execute_splices_json_literal_task_directive_for_object_seed() {
1419 use mlua_swarm::Operator;
1420 use tokio::sync::mpsc;
1421
1422 let (tx, mut rx) = mpsc::unbounded_channel();
1423 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1424 SessionId::parse("S-objseed").unwrap(),
1425 tx,
1426 None,
1427 ));
1428
1429 let ctx = test_ctx("ST-objseed");
1430 let rendered_prompt = serde_json::json!({"key": "value"});
1435
1436 let session_bg = session.clone();
1437 let handle = tokio::spawn(async move {
1438 session_bg
1439 .execute(
1440 &ctx,
1441 None,
1442 rendered_prompt,
1443 Some(test_worker_binding()),
1444 test_cap_token(),
1445 )
1446 .await
1447 });
1448
1449 let sent = rx.recv().await.expect("Spawn sent");
1450 let req_id = match sent {
1451 ServerMsg::Spawn {
1452 req_id, directive, ..
1453 } => {
1454 let directive = directive.as_str();
1455 assert!(
1456 directive.contains(r#"task_directive: {"key":"value"}"#),
1457 "directive missing JSON-literal task_directive splice: {directive}"
1458 );
1459 req_id
1460 }
1461 other => panic!("expected Spawn, got {other:?}"),
1462 };
1463
1464 session
1465 .resolve_pending(
1466 &req_id,
1467 PendingReply::SpawnAck {
1468 value: serde_json::json!({}),
1469 ok: true,
1470 error: None,
1471 },
1472 )
1473 .await;
1474 handle.await.expect("join").expect("execute Ok");
1475 }
1476}