1use async_trait::async_trait;
14use mlua_swarm::core::agent_context::{AgentContextView, PROJECTION_PLACEMENT_KEY};
15use mlua_swarm::core::projection::{
16 FileProjectionAdapter, ProjectionAdapter, ProjectionKey, ProjectionRef,
17};
18use mlua_swarm::core::projection_placement::ProjectionPlacement;
19use mlua_swarm::{
20 CapToken, Ctx, Operator, SeniorBridge, SessionId, SpawnHook, StepId, WorkerBinding,
21 WorkerError, WorkerResult,
22};
23use serde_json::Value;
24use std::collections::HashMap;
25use tokio::sync::{mpsc, oneshot, Mutex};
26
27use super::protocol::{current_parent_req_id, PendingReply, ServerMsg};
28
29pub struct WSOperatorSession {
31 sid: SessionId,
32 tx: Mutex<Option<mpsc::UnboundedSender<ServerMsg>>>,
35 pending: Mutex<HashMap<String, oneshot::Sender<PendingReply>>>,
38 base_url: Option<std::sync::Arc<str>>,
44}
45
46impl WSOperatorSession {
47 pub(super) fn new_with_base_url(
57 sid: SessionId,
58 tx: mpsc::UnboundedSender<ServerMsg>,
59 base_url: Option<std::sync::Arc<str>>,
60 ) -> Self {
61 Self {
62 sid,
63 tx: Mutex::new(Some(tx)),
64 pending: Mutex::new(HashMap::new()),
65 base_url,
66 }
67 }
68
69 pub(super) async fn replace_tx(&self, new_tx: mpsc::UnboundedSender<ServerMsg>) {
71 *self.tx.lock().await = Some(new_tx);
72 }
73
74 pub(super) async fn is_connected(&self) -> bool {
76 self.tx.lock().await.is_some()
77 }
78
79 pub(super) async fn clear_tx_if(&self, expected: &mpsc::UnboundedSender<ServerMsg>) {
84 let mut current = self.tx.lock().await;
85 if current
86 .as_ref()
87 .is_some_and(|sender| sender.same_channel(expected))
88 {
89 *current = None;
90 }
91 }
92
93 pub(crate) async fn clear_tx(&self) {
95 *self.tx.lock().await = None;
96 }
97
98 pub(crate) async fn fail_pending(&self, reason: &str) {
116 let drained: Vec<(String, oneshot::Sender<PendingReply>)> =
117 self.pending.lock().await.drain().collect();
118 if !drained.is_empty() {
119 tracing::warn!(
120 sid = %self.sid,
121 count = drained.len(),
122 reason,
123 "ws operator session: failing in-flight pending replies"
124 );
125 }
126 }
129
130 pub(super) async fn resolve_pending(&self, req_id: &str, reply: PendingReply) {
133 if let Some(otx) = self.pending.lock().await.remove(req_id) {
134 let _ = otx.send(reply);
135 }
136 }
137
138 async fn send_and_await(&self, req_id: String, msg: ServerMsg) -> Result<PendingReply, String> {
142 let (otx, orx) = oneshot::channel::<PendingReply>();
143 self.pending.lock().await.insert(req_id.clone(), otx);
144
145 let send_result = {
147 let guard = self.tx.lock().await;
148 match guard.as_ref() {
149 Some(tx) => tx
150 .send(msg)
151 .map_err(|_| "ws send channel closed".to_string()),
152 None => Err("ws operator disconnected".to_string()),
153 }
154 };
155 if let Err(e) = send_result {
156 self.pending.lock().await.remove(&req_id);
157 return Err(e);
158 }
159
160 orx.await
161 .map_err(|_| "ws operator: oneshot cancelled (= reply path closed)".to_string())
162 }
163
164 async fn send_oneway(&self, msg: ServerMsg) -> Result<(), String> {
166 let guard = self.tx.lock().await;
167 match guard.as_ref() {
168 Some(tx) => tx
169 .send(msg)
170 .map_err(|_| "ws send channel closed".to_string()),
171 None => Err("ws operator disconnected".to_string()),
172 }
173 }
174}
175
176#[async_trait]
177impl SeniorBridge for WSOperatorSession {
178 async fn ask(&self, task_id: &StepId, question: Value) -> Result<Value, String> {
179 let req_id = format!("{}-ask-{}", self.sid, uuid::Uuid::new_v4());
180 let msg = ServerMsg::Ask {
181 req_id: req_id.clone(),
182 parent_req_id: current_parent_req_id(),
183 task_id: task_id.clone(),
184 question,
185 };
186 match self.send_and_await(req_id, msg).await? {
187 PendingReply::Answer(v) => Ok(v),
188 PendingReply::HookAck { .. } => {
189 Err("ws operator: unexpected hook_ack reply to ask".into())
190 }
191 PendingReply::SpawnAck { .. } => {
192 Err("ws operator: unexpected spawn_ack reply to ask".into())
193 }
194 PendingReply::SpawnHalt { .. } => {
195 Err("ws operator: unexpected spawn_halt reply to ask".into())
196 }
197 }
198 }
199}
200
201#[async_trait]
202impl SpawnHook for WSOperatorSession {
203 async fn before(&self, ctx: &Ctx) -> Result<(), String> {
204 let req_id = format!("{}-hb-{}", self.sid, uuid::Uuid::new_v4());
205 let msg = ServerMsg::HookBefore {
206 req_id: req_id.clone(),
207 parent_req_id: current_parent_req_id(),
208 task_id: ctx.task_id.clone(),
209 agent: ctx.agent.clone(),
210 attempt: ctx.attempt,
211 };
212 match self.send_and_await(req_id, msg).await? {
213 PendingReply::HookAck { ok: true, .. } => Ok(()),
214 PendingReply::HookAck { ok: false, reason } => {
215 Err(reason.unwrap_or_else(|| "ws operator: spawn rejected".into()))
216 }
217 PendingReply::Answer(_) => {
218 Err("ws operator: unexpected answer reply to hook_before".into())
219 }
220 PendingReply::SpawnAck { .. } => {
221 Err("ws operator: unexpected spawn_ack reply to hook_before".into())
222 }
223 PendingReply::SpawnHalt { .. } => {
224 Err("ws operator: unexpected spawn_halt reply to hook_before".into())
225 }
226 }
227 }
228
229 async fn after(&self, ctx: &Ctx, result: &Value) -> Result<(), String> {
230 let req_id = format!("{}-ha-{}", self.sid, uuid::Uuid::new_v4());
231 let msg = ServerMsg::HookAfter {
232 req_id,
233 parent_req_id: current_parent_req_id(),
234 task_id: ctx.task_id.clone(),
235 agent: ctx.agent.clone(),
236 attempt: ctx.attempt,
237 result: result.clone(),
238 };
239 let _ = self.send_oneway(msg).await;
241 Ok(())
242 }
243}
244
245#[async_trait]
246impl Operator for WSOperatorSession {
247 async fn execute(
270 &self,
271 ctx: &Ctx,
272 _system: Option<String>,
273 prompt: Value,
274 worker: Option<WorkerBinding>,
275 worker_token: CapToken,
276 ) -> Result<WorkerResult, WorkerError> {
277 let Some(worker) = worker else {
278 return Err(WorkerError::Failed(format!(
279 "agent '{}' has no worker_binding; WS thin-path requires one \
280 (Blueprint AgentDef.profile.worker_binding)",
281 ctx.agent
282 )));
283 };
284 let req_id = format!("{}-spawn-{}", self.sid, uuid::Uuid::new_v4());
285 let worker_handle = ctx
286 .meta
287 .runtime
288 .get("worker_handle")
289 .and_then(|v| v.as_str())
290 .map(|s| s.to_string());
291 let data_sink_endpoint = ctx
292 .meta
293 .runtime
294 .get("data_sink_endpoint")
295 .and_then(|v| v.as_str());
296 let run_id = ctx.meta.runtime.get("run_id").and_then(|v| v.as_str());
301 let view = AgentContextView::materialized_or_from_ctx(ctx);
310 let directive = default_spawn_directive_with_task_directive(
317 &ctx.agent,
318 ctx.task_id.as_str(),
319 &worker.variant,
320 &view,
321 data_sink_endpoint,
322 self.base_url.as_deref(),
323 run_id,
324 &prompt,
325 );
326 let projection_placement = ctx
334 .meta
335 .runtime
336 .get(PROJECTION_PLACEMENT_KEY)
337 .and_then(|v| serde_json::from_value::<ProjectionPlacement>(v.clone()).ok())
338 .unwrap_or_default();
339 let directive = append_projection_pointer(
344 directive,
345 &ctx.task_id,
346 &view,
347 run_id,
348 &projection_placement,
349 );
350 let msg = ServerMsg::Spawn {
351 req_id: req_id.clone(),
352 parent_req_id: current_parent_req_id(),
353 task_id: ctx.task_id.clone(),
354 agent: ctx.agent.clone(),
355 attempt: ctx.attempt,
356 capability_token: worker_token.encode(),
357 worker_handle,
358 worker: Some(worker),
359 directive,
360 };
361 match self.send_and_await(req_id, msg).await {
362 Ok(PendingReply::SpawnAck {
363 value,
364 ok,
365 error: None,
366 stats,
367 }) => Ok(WorkerResult {
368 value,
369 ok,
370 stats: stats.and_then(decode_ack_stats),
375 }),
376 Ok(PendingReply::SpawnAck {
377 error: Some(msg), ..
378 }) => Err(WorkerError::Failed(msg)),
379 Ok(PendingReply::SpawnHalt { value, reason }) => {
386 let marker = serde_json::json!({
387 "halted": true,
388 "reason": reason,
389 "value": value,
390 });
391 Ok(WorkerResult {
392 value: marker,
393 ok: true,
394 stats: None,
395 })
396 }
397 Ok(_) => Err(WorkerError::Failed(
398 "ws operator: unexpected non-spawn reply".into(),
399 )),
400 Err(e) => Err(WorkerError::Failed(format!("ws operator spawn: {e}"))),
401 }
402 }
403
404 fn requires_worker_binding(&self) -> bool {
405 true
406 }
407}
408
409fn decode_ack_stats(v: serde_json::Value) -> Option<mlua_swarm::store::trace::WorkerStats> {
415 let mut stats: mlua_swarm::store::trace::WorkerStats = serde_json::from_value(v).ok()?;
416 if stats.worker_kind.is_none() {
417 stats.worker_kind = Some("operator".to_string());
418 }
419 if stats.is_empty() {
420 None
421 } else {
422 Some(stats)
423 }
424}
425
426#[allow(clippy::too_many_arguments)]
484pub(super) fn default_spawn_directive(
485 agent: &str,
486 task_id: &str,
487 subagent_type: &str,
488 view: &AgentContextView,
489 data_sink_endpoint: Option<&str>,
490 base_url: Option<&str>,
491 run_id: Option<&str>,
492) -> String {
493 let context_header = view.to_directive_header();
497 let data_endpoint_block = match data_sink_endpoint {
504 Some(base) => format!(
505 "\n\
506 [Data path endpoint — MainAgent reminder]\n\
507 When you kick a SubAgent, inject the following two lines into\n\
508 its prompt / environment so Big Response payloads (4k+ tokens,\n\
509 files, intermediate artifacts) flow directly to the Store owner,\n\
510 bypassing the MainAgent (context stays small; only the out_id\n\
511 ref is passed around).\n \
512 DATA_EMIT: {base}/v1/data/emit (POST, auth = Bearer worker_handle or ?token=)\n \
513 DATA_GET: {base}/v1/data/<out_id|out_name> (the next SubAgent fetches from $IN_REFS)\n\
514 When a SubAgent produces a Big Response, POST it to DATA_EMIT\n\
515 and return only the one-line out_id ref (do not mix the body\n\
516 in; the MainAgent must not answer directly).\n\
517 \n"
518 ),
519 None => String::new(),
520 };
521 let main_ai_reminder = match view.project_name_alias.as_deref() {
522 Some(a) => format!(
523 "\n\
524 [LDS Session Alias Reminder — MainAI mandatory]\n\
525 Before kicking the SubAgent below, call:\n \
526 mcp__lds__session_create(root=<working_dir>, alias=\"{a}\")\n\
527 (= establish a single task-level lds session; reuse on repeated dispatch).\n\
528 Then add this literal line to the SubAgent prompt body below:\n \
529 LDS Session Alias: {a}\n\
530 The SubAgent will call mcp__lds__session_start(alias=\"{a}\") on init,\n\
531 keeping worktree ownership unified across dispatches.\n\
532 (Full discipline rationale is inlined above; reach is via this directive itself,\n\
533 not via any external doc path. The 2 steps above are the complete contract.)\n\
534 \n"
535 ),
536 None => String::new(),
537 };
538 let base_url_line = match base_url {
542 Some(u) => u.to_string(),
543 None => "<your server's actual bind — check with mse_doctor>".to_string(),
544 };
545 let run_route_line = match run_id {
550 Some(rid) => format!("GET <base_url>/v1/runs/{rid}"),
551 None => "GET <base_url>/v1/runs/<run_id>".to_string(),
552 };
553 format!(
554 "[agent_primitive dispatch=@{agent}]\n\
555 worker endpoint:\n \
556 GET <base_url>/v1/worker/prompt?task_id={task_id}\n \
557 POST <base_url>/v1/worker/submit\n\
558 auth: Bearer <worker_handle from THIS Spawn payload (= short `wh-XXXXXXXX` form)>\n\
559 task_id: {task_id}\n\
560 agent_id: {agent}\n\
561 {context_header}\
562 {data_endpoint_block}\
563 {main_ai_reminder}\
564 Kick a SubAgent via Agent tool with subagent_type=\"{subagent_type}\" (= project-local \
565 `.claude/agents/{subagent_type}.md`, this agent's Blueprint-declared worker binding). \
566 The prompt you pass to it MUST be EXACTLY these 4 lines (no preamble, no extra text):\n\
567 \n \
568 agent_id: {agent}\n \
569 worker_handle: <THIS Spawn payload's `worker_handle` field (short string `wh-XXXXXXXX`)>\n \
570 base_url: {base_url_line}\n \
571 task_id: {task_id}\n\
572 \n\
573 The SubAgent self-fetches system + prompt via GET (Bearer = handle), \
574 executes as agent @{agent}, POSTs raw body to /v1/worker/submit (Bearer = handle, \
575 server resolves task_id from handle), and replies `OUTPUT` 1 word. You then forward \
576 SpawnAck {{req_id, value:{{}}, ok:true}} through your operator client — MCP path: \
577 mse_ack(sid, req_id, kind=\"spawn_ack\", ok=true) (= empty value because canonical \
578 body lives in output_tail via the POST). \
579 Do NOT fetch /v1/worker/prompt yourself. Do NOT wrap, summarize, or field-select \
580 the SubAgent reply. Observation / debug is a separate channel (= agent-inspect MCP / \
581 {run_route_line}), do NOT mix it into the forward path. \
582 If the SubAgent type is not registered, FAIL LOUD: reply SpawnAck ok=false with an \
583 error explaining the missing `.claude/agents/{subagent_type}.md` — do NOT fall back \
584 to another subagent_type."
585 )
586}
587
588#[allow(clippy::too_many_arguments)]
602pub(super) fn default_spawn_directive_with_task_directive(
603 agent: &str,
604 task_id: &str,
605 subagent_type: &str,
606 view: &AgentContextView,
607 data_sink_endpoint: Option<&str>,
608 base_url: Option<&str>,
609 run_id: Option<&str>,
610 task_directive: &Value,
611) -> String {
612 let base = default_spawn_directive(
613 agent,
614 task_id,
615 subagent_type,
616 view,
617 data_sink_endpoint,
618 base_url,
619 run_id,
620 );
621 let task_directive_line = match task_directive {
626 Value::Null => String::new(),
627 Value::String(s) => format!("task_directive: {s}\n"),
628 other => format!("task_directive: {other}\n"),
629 };
630 format!("{base}{task_directive_line}")
631}
632
633fn append_projection_pointer(
668 directive: String,
669 task_id: &StepId,
670 view: &AgentContextView,
671 run_id: Option<&str>,
672 placement: &ProjectionPlacement,
673) -> String {
674 let Some(root) = placement.resolve_root(view) else {
675 return directive;
676 };
677 match serde_json::to_value(view) {
678 Ok(ctx_data) => {
679 let key = ProjectionKey {
680 task_id: task_id.to_string(),
681 run_id: run_id.map(str::to_string),
682 step: None,
683 path: None,
684 };
685 let adapter = FileProjectionAdapter::with_placement(root, placement.clone());
686 match adapter.project(&key, &ctx_data) {
687 Ok(reference) => {
688 let pointer_value = match &reference {
689 ProjectionRef::File { path } => serde_json::json!({ "file": path }),
690 ProjectionRef::Query { endpoint, key } => {
691 serde_json::json!({ "endpoint": endpoint, "key": key })
692 }
693 };
694 format!("{directive}ctx_projection: {pointer_value}\n")
695 }
696 Err(err) => {
697 tracing::warn!(
698 %task_id,
699 error = %err,
700 "projection hook: materialize failed, spawning without a pointer"
701 );
702 directive
703 }
704 }
705 }
706 Err(err) => {
707 tracing::warn!(
708 %task_id,
709 error = %err,
710 "projection hook: AgentContextView serialize failed, spawning without a pointer"
711 );
712 directive
713 }
714 }
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720 use mlua_swarm::core::agent_context::{
721 TASK_METADATA_KEY, TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY,
722 };
723
724 fn view_with(
731 alias: Option<&str>,
732 project_root: Option<&str>,
733 work_dir: Option<&str>,
734 ) -> AgentContextView {
735 AgentContextView {
736 project_name_alias: alias.map(String::from),
737 project_root: project_root.map(String::from),
738 work_dir: work_dir.map(String::from),
739 ..AgentContextView::default()
740 }
741 }
742
743 #[tokio::test]
744 async fn connection_state_tracks_the_current_sender() {
745 let (tx, _rx) = mpsc::unbounded_channel();
746 let session = WSOperatorSession::new_with_base_url(
747 SessionId::parse("S-connection-state").unwrap(),
748 tx.clone(),
749 None,
750 );
751 assert!(session.is_connected().await);
752
753 session.clear_tx_if(&tx).await;
754 assert!(!session.is_connected().await);
755 }
756
757 #[tokio::test]
758 async fn stale_disconnect_does_not_clear_a_reconnected_sender() {
759 let (old_tx, _old_rx) = mpsc::unbounded_channel();
760 let (new_tx, _new_rx) = mpsc::unbounded_channel();
761 let session = WSOperatorSession::new_with_base_url(
762 SessionId::parse("S-reconnect-state").unwrap(),
763 old_tx.clone(),
764 None,
765 );
766
767 session.replace_tx(new_tx).await;
768 session.clear_tx_if(&old_tx).await;
769
770 assert!(session.is_connected().await);
771 }
772
773 #[test]
774 fn directive_omits_project_name_alias_when_none() {
775 let d = default_spawn_directive(
776 "impl-lead",
777 "task-x",
778 "mse-worker-coder",
779 &view_with(None, None, None),
780 None,
781 None,
782 None,
783 );
784 assert!(!d.contains("project_name_alias:"));
785 assert!(!d.contains("LDS Session Alias"));
786 assert!(!d.contains("session_create"));
787 }
788
789 #[test]
790 fn directive_emits_project_name_alias_when_some() {
791 let d = default_spawn_directive(
792 "impl-lead",
793 "task-x",
794 "mse-worker-coder",
795 &view_with(Some("mse-task-7785"), None, None),
796 None,
797 None,
798 None,
799 );
800 assert!(
802 d.contains("project_name_alias: mse-task-7785"),
803 "directive missing project_name_alias header: {d}"
804 );
805 assert!(
807 d.contains("mcp__lds__session_create(root=<working_dir>, alias=\"mse-task-7785\")"),
808 "directive missing session_create reminder: {d}"
809 );
810 assert!(
811 d.contains("LDS Session Alias: mse-task-7785"),
812 "directive missing SubAgent prompt inject line: {d}"
813 );
814 assert!(
816 d.contains("inlined above") || d.contains("complete contract"),
817 "directive should inline rationale rather than point at external doc: {d}"
818 );
819 let forbidden_doc_ref = format!(".{}/CLAUDE.md", "claude");
828 assert!(
829 !d.contains(&forbidden_doc_ref),
830 "directive must not reference {forbidden_doc_ref} (out of MainAI scope): {d}"
831 );
832 }
833
834 #[test]
835 fn directive_omits_data_endpoint_when_none() {
836 let d = default_spawn_directive(
837 "impl-lead",
838 "task-x",
839 "mse-worker-coder",
840 &view_with(None, None, None),
841 None,
842 None,
843 None,
844 );
845 assert!(!d.contains("[Data path endpoint"));
846 assert!(!d.contains("DATA_EMIT"));
847 assert!(!d.contains("DATA_GET"));
848 }
849
850 #[test]
851 fn directive_emits_data_endpoint_when_some() {
852 let base = "http://127.0.0.1:7785";
853 let d = default_spawn_directive(
854 "impl-lead",
855 "task-x",
856 "mse-worker-coder",
857 &view_with(None, None, None),
858 Some(base),
859 None,
860 None,
861 );
862 assert!(
863 d.contains("[Data path endpoint"),
864 "directive missing data endpoint block header: {d}"
865 );
866 assert!(
867 d.contains(&format!("DATA_EMIT: {base}/v1/data/emit")),
868 "directive missing single-mouth emit line: {d}"
869 );
870 assert!(
871 d.contains("Bearer worker_handle or ?token="),
872 "directive missing auth transport hint: {d}"
873 );
874 assert!(
875 d.contains(&format!("DATA_GET: {base}/v1/data/<out_id|out_name>")),
876 "directive missing GET line: {d}"
877 );
878 assert!(
879 !d.contains("emit-auth"),
880 "old split endpoint must not leak into directive: {d}"
881 );
882 assert!(
883 d.contains("bypassing the MainAgent") && d.contains("out_id ref"),
884 "directive should carry the ownership + bypass reasoning: {d}"
885 );
886 }
887
888 #[test]
889 fn directive_carries_declared_subagent_type_and_has_no_fallback() {
890 let d = default_spawn_directive(
891 "impl-lead",
892 "task-x",
893 "mse-worker-coder",
894 &view_with(None, None, None),
895 None,
896 None,
897 None,
898 );
899 assert!(
900 d.contains("subagent_type=\"mse-worker-coder\""),
901 "directive must carry the Blueprint-declared subagent_type literally: {d}"
902 );
903 assert!(
904 d.contains(".claude/agents/mse-worker-coder.md"),
905 "directive must reference the declared subagent's own .md path: {d}"
906 );
907 assert!(
909 !d.contains("general-purpose"),
910 "directive must not fall back to subagent_type=\"general-purpose\": {d}"
911 );
912 assert!(
913 !d.contains("mse-worker\""),
914 "directive must not carry the old hardcoded \"mse-worker\" literal: {d}"
915 );
916 assert!(
917 d.contains("FAIL LOUD"),
918 "directive must instruct the MainAI to fail loud instead of falling back: {d}"
919 );
920 }
921
922 #[test]
928 fn directive_renders_actual_base_url_when_some() {
929 let d = default_spawn_directive(
930 "impl-lead",
931 "task-x",
932 "mse-worker-coder",
933 &view_with(None, None, None),
934 None,
935 Some("http://127.0.0.1:8888"),
936 None,
937 );
938 assert!(
939 d.contains("base_url: http://127.0.0.1:8888"),
940 "directive must render the actual bind literally: {d}"
941 );
942 assert!(
943 !d.contains("mse_doctor"),
944 "no mse_doctor detour when bind is known: {d}"
945 );
946 }
947
948 #[test]
952 fn directive_falls_back_to_mse_doctor_pointer_when_none() {
953 let d = default_spawn_directive(
954 "impl-lead",
955 "task-x",
956 "mse-worker-coder",
957 &view_with(None, None, None),
958 None,
959 None,
960 None,
961 );
962 assert!(
963 d.contains("check with mse_doctor"),
964 "fallback must point at mse_doctor: {d}"
965 );
966 }
967
968 #[test]
972 fn directive_never_contains_stale_example_port_7786() {
973 for base in [
974 None,
975 Some("http://127.0.0.1:7777"),
976 Some("http://192.0.2.1:9000"),
977 ] {
978 let d = default_spawn_directive(
979 "impl-lead",
980 "task-x",
981 "mse-worker-coder",
982 &view_with(Some("mse-task-alias"), None, None),
983 Some("http://127.0.0.1:7785"),
984 base,
985 None,
986 );
987 assert!(
988 !d.contains("7786"),
989 "stale example port 7786 leaked: base={base:?}, d={d}"
990 );
991 }
992 }
993
994 #[test]
1000 fn directive_never_contains_stale_tasks_id_route() {
1001 let d = default_spawn_directive(
1002 "impl-lead",
1003 "task-x",
1004 "mse-worker-coder",
1005 &view_with(None, None, None),
1006 None,
1007 None,
1008 Some("R-abc123"),
1009 );
1010 assert!(
1011 !d.contains("/v1/tasks/{id}") && !d.contains("/v1/tasks/{{id}}"),
1012 "stale /v1/tasks/{{id}} observation hint leaked: {d}"
1013 );
1014 }
1015
1016 #[test]
1019 fn directive_renders_actual_run_id_when_some() {
1020 let d = default_spawn_directive(
1021 "impl-lead",
1022 "task-x",
1023 "mse-worker-coder",
1024 &view_with(None, None, None),
1025 None,
1026 None,
1027 Some("R-abc123"),
1028 );
1029 assert!(
1030 d.contains("GET <base_url>/v1/runs/R-abc123"),
1031 "directive missing real run_id in observation route: {d}"
1032 );
1033 }
1034
1035 #[test]
1038 fn directive_falls_back_to_run_id_placeholder_when_none() {
1039 let d = default_spawn_directive(
1040 "impl-lead",
1041 "task-x",
1042 "mse-worker-coder",
1043 &view_with(None, None, None),
1044 None,
1045 None,
1046 None,
1047 );
1048 assert!(
1049 d.contains("GET <base_url>/v1/runs/<run_id>"),
1050 "directive missing placeholder observation route: {d}"
1051 );
1052 }
1053
1054 #[test]
1059 fn directive_omits_project_root_and_work_dir_when_both_none() {
1060 let d = default_spawn_directive(
1061 "impl-lead",
1062 "task-x",
1063 "mse-worker-coder",
1064 &view_with(None, None, None),
1065 None,
1066 None,
1067 None,
1068 );
1069 assert!(!d.contains("project_root:"));
1070 assert!(!d.contains("work_dir:"));
1071 }
1072
1073 #[test]
1076 fn directive_splices_project_root_and_work_dir_when_both_present() {
1077 let d = default_spawn_directive(
1078 "impl-lead",
1079 "task-x",
1080 "mse-worker-coder",
1081 &view_with(None, Some("/repo"), Some("/repo/work")),
1082 None,
1083 None,
1084 None,
1085 );
1086 assert!(
1087 d.contains("project_root: /repo"),
1088 "directive missing project_root header: {d}"
1089 );
1090 assert!(
1091 d.contains("work_dir: /repo/work"),
1092 "directive missing work_dir header: {d}"
1093 );
1094 }
1095
1096 #[test]
1099 fn directive_splices_project_root_only_when_work_dir_absent() {
1100 let d = default_spawn_directive(
1101 "impl-lead",
1102 "task-x",
1103 "mse-worker-coder",
1104 &view_with(None, Some("/repo"), None),
1105 None,
1106 None,
1107 None,
1108 );
1109 assert!(
1110 d.contains("project_root: /repo"),
1111 "directive missing project_root header: {d}"
1112 );
1113 assert!(!d.contains("work_dir:"));
1114 }
1115
1116 #[test]
1123 fn directive_splices_task_metadata_when_some() {
1124 let view = AgentContextView {
1125 task_metadata: Some(serde_json::json!({"issue": 20})),
1126 ..view_with(None, Some("/repo"), None)
1127 };
1128 let d = default_spawn_directive(
1129 "impl-lead",
1130 "task-x",
1131 "mse-worker-coder",
1132 &view,
1133 None,
1134 None,
1135 None,
1136 );
1137 assert!(
1138 d.contains(r#"task_metadata: {"issue":20}"#),
1139 "directive missing task_metadata header: {d}"
1140 );
1141 assert!(d.contains("project_root: /repo"));
1143 }
1144
1145 #[test]
1149 fn directive_omits_task_metadata_when_none() {
1150 let d = default_spawn_directive(
1151 "impl-lead",
1152 "task-x",
1153 "mse-worker-coder",
1154 &view_with(None, None, None),
1155 None,
1156 None,
1157 None,
1158 );
1159 assert!(!d.contains("task_metadata:"));
1160 }
1161
1162 fn test_ctx(task_id: &str) -> mlua_swarm::Ctx {
1165 mlua_swarm::Ctx::new(mlua_swarm::StepId::parse(task_id).unwrap(), 1, "a")
1166 }
1167
1168 fn test_worker_binding() -> mlua_swarm::WorkerBinding {
1169 mlua_swarm::WorkerBinding {
1170 variant: "test-variant".into(),
1171 tools: vec![],
1172 request_digest: None,
1173 requested_model: None,
1174 }
1175 }
1176
1177 fn test_cap_token() -> mlua_swarm::CapToken {
1178 mlua_swarm::CapToken {
1179 agent_id: "a".into(),
1180 role: mlua_swarm::Role::Worker,
1181 scopes: vec!["*".into()],
1182 issued_at: 0,
1183 expire_at: u64::MAX / 2,
1184 max_uses: None,
1185 nonce: "test-nonce".into(),
1186 sig_hex: "".into(),
1187 }
1188 }
1189
1190 #[tokio::test]
1196 async fn spawn_halt_reply_lands_as_ok_worker_result_with_marker() {
1197 use mlua_swarm::Operator;
1198 use tokio::sync::mpsc;
1199
1200 let (tx, mut rx) = mpsc::unbounded_channel();
1201 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1202 SessionId::parse("S-halt").unwrap(),
1203 tx,
1204 None,
1205 ));
1206
1207 let session_bg = session.clone();
1210 let handle = tokio::spawn(async move {
1211 session_bg
1212 .execute(
1213 &test_ctx("ST-halt"),
1214 None,
1215 "".into(),
1216 Some(test_worker_binding()),
1217 test_cap_token(),
1218 )
1219 .await
1220 });
1221
1222 let sent = rx.recv().await.expect("Spawn sent");
1223 let req_id = match sent {
1224 ServerMsg::Spawn { req_id, .. } => req_id,
1225 other => panic!("expected Spawn, got {other:?}"),
1226 };
1227
1228 session
1229 .resolve_pending(
1230 &req_id,
1231 PendingReply::SpawnHalt {
1232 value: serde_json::json!({"partial": "abc"}),
1233 reason: Some("shape verified".into()),
1234 },
1235 )
1236 .await;
1237
1238 let result = handle.await.expect("join").expect("execute Ok");
1239 assert!(
1240 result.ok,
1241 "spawn_halt must land as ok=true (normal termination), got: {result:?}"
1242 );
1243 assert_eq!(result.value["halted"], true);
1244 assert_eq!(result.value["reason"], "shape verified");
1245 assert_eq!(result.value["value"], serde_json::json!({"partial": "abc"}));
1246 }
1247
1248 #[tokio::test]
1251 async fn spawn_ack_with_error_still_lands_as_worker_error() {
1252 use mlua_swarm::{Operator, WorkerError};
1253 use tokio::sync::mpsc;
1254
1255 let (tx, mut rx) = mpsc::unbounded_channel();
1256 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1257 SessionId::parse("S-err").unwrap(),
1258 tx,
1259 None,
1260 ));
1261
1262 let session_bg = session.clone();
1263 let handle = tokio::spawn(async move {
1264 session_bg
1265 .execute(
1266 &test_ctx("ST-err"),
1267 None,
1268 "".into(),
1269 Some(test_worker_binding()),
1270 test_cap_token(),
1271 )
1272 .await
1273 });
1274
1275 let sent = rx.recv().await.expect("Spawn sent");
1276 let req_id = match sent {
1277 ServerMsg::Spawn { req_id, .. } => req_id,
1278 other => panic!("expected Spawn, got {other:?}"),
1279 };
1280
1281 session
1282 .resolve_pending(
1283 &req_id,
1284 PendingReply::SpawnAck {
1285 value: serde_json::json!({}),
1286 ok: false,
1287 error: Some("real crash".into()),
1288 stats: None,
1289 },
1290 )
1291 .await;
1292
1293 let err = handle.await.expect("join").expect_err("must be error");
1294 assert!(matches!(err, WorkerError::Failed(msg) if msg.contains("real crash")));
1295 }
1296
1297 #[tokio::test]
1303 async fn fail_pending_unblocks_a_parked_spawn_with_worker_error() {
1304 use mlua_swarm::{Operator, WorkerError};
1305 use tokio::sync::mpsc;
1306
1307 let (tx, mut rx) = mpsc::unbounded_channel();
1308 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1309 SessionId::parse("S-teardown").unwrap(),
1310 tx,
1311 None,
1312 ));
1313
1314 let session_bg = session.clone();
1315 let handle = tokio::spawn(async move {
1316 session_bg
1317 .execute(
1318 &test_ctx("ST-teardown"),
1319 None,
1320 "".into(),
1321 Some(test_worker_binding()),
1322 test_cap_token(),
1323 )
1324 .await
1325 });
1326
1327 let _sent = rx.recv().await.expect("Spawn sent");
1331
1332 session.fail_pending("operator session torn down").await;
1333
1334 let err = handle
1335 .await
1336 .expect("join")
1337 .expect_err("a parked spawn must fail once pending is drained");
1338 assert!(
1339 matches!(err, WorkerError::Failed(_)),
1340 "fail_pending must surface a WorkerError::Failed, got: {err:?}"
1341 );
1342 }
1343
1344 #[tokio::test]
1351 async fn execute_splices_project_root_and_work_dir_from_ctx_meta_runtime() {
1352 use mlua_swarm::Operator;
1353 use tokio::sync::mpsc;
1354
1355 let (tx, mut rx) = mpsc::unbounded_channel();
1356 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1357 SessionId::parse("S-ctxroot").unwrap(),
1358 tx,
1359 None,
1360 ));
1361
1362 let mut ctx = test_ctx("ST-ctxroot");
1363 ctx.meta.runtime.insert(
1364 TASK_PROJECT_ROOT_KEY.to_string(),
1365 serde_json::json!("/repo"),
1366 );
1367 ctx.meta.runtime.insert(
1368 TASK_WORK_DIR_KEY.to_string(),
1369 serde_json::json!("/repo/work"),
1370 );
1371
1372 let session_bg = session.clone();
1373 let handle = tokio::spawn(async move {
1374 session_bg
1375 .execute(
1376 &ctx,
1377 None,
1378 "".into(),
1379 Some(test_worker_binding()),
1380 test_cap_token(),
1381 )
1382 .await
1383 });
1384
1385 let sent = rx.recv().await.expect("Spawn sent");
1386 let req_id = match sent {
1387 ServerMsg::Spawn {
1388 req_id, directive, ..
1389 } => {
1390 let directive = directive.as_str();
1394 assert!(
1395 directive.contains("project_root: /repo"),
1396 "directive missing project_root splice: {directive}"
1397 );
1398 assert!(
1399 directive.contains("work_dir: /repo/work"),
1400 "directive missing work_dir 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 stats: None,
1415 },
1416 )
1417 .await;
1418 handle.await.expect("join").expect("execute Ok");
1419 }
1420
1421 #[tokio::test]
1426 async fn execute_splices_project_root_only_when_ctx_meta_runtime_partial() {
1427 use mlua_swarm::Operator;
1428 use tokio::sync::mpsc;
1429
1430 let (tx, mut rx) = mpsc::unbounded_channel();
1431 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1432 SessionId::parse("S-ctxpartial").unwrap(),
1433 tx,
1434 None,
1435 ));
1436
1437 let mut ctx = test_ctx("ST-ctxpartial");
1438 ctx.meta.runtime.insert(
1439 TASK_PROJECT_ROOT_KEY.to_string(),
1440 serde_json::json!("/repo"),
1441 );
1442
1443 let session_bg = session.clone();
1444 let handle = tokio::spawn(async move {
1445 session_bg
1446 .execute(
1447 &ctx,
1448 None,
1449 "".into(),
1450 Some(test_worker_binding()),
1451 test_cap_token(),
1452 )
1453 .await
1454 });
1455
1456 let sent = rx.recv().await.expect("Spawn sent");
1457 let req_id = match sent {
1458 ServerMsg::Spawn {
1459 req_id, directive, ..
1460 } => {
1461 let directive = directive.as_str();
1462 assert!(
1463 directive.contains("project_root: /repo"),
1464 "directive missing project_root splice: {directive}"
1465 );
1466 assert!(!directive.contains("work_dir:"));
1467 req_id
1468 }
1469 other => panic!("expected Spawn, got {other:?}"),
1470 };
1471
1472 session
1473 .resolve_pending(
1474 &req_id,
1475 PendingReply::SpawnAck {
1476 value: serde_json::json!({}),
1477 ok: true,
1478 error: None,
1479 stats: None,
1480 },
1481 )
1482 .await;
1483 handle.await.expect("join").expect("execute Ok");
1484 }
1485
1486 #[tokio::test]
1490 async fn execute_omits_project_root_and_work_dir_when_ctx_meta_runtime_absent() {
1491 use mlua_swarm::Operator;
1492 use tokio::sync::mpsc;
1493
1494 let (tx, mut rx) = mpsc::unbounded_channel();
1495 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1496 SessionId::parse("S-ctxabsent").unwrap(),
1497 tx,
1498 None,
1499 ));
1500
1501 let ctx = test_ctx("ST-ctxabsent");
1502
1503 let session_bg = session.clone();
1504 let handle = tokio::spawn(async move {
1505 session_bg
1506 .execute(
1507 &ctx,
1508 None,
1509 "".into(),
1510 Some(test_worker_binding()),
1511 test_cap_token(),
1512 )
1513 .await
1514 });
1515
1516 let sent = rx.recv().await.expect("Spawn sent");
1517 let req_id = match sent {
1518 ServerMsg::Spawn {
1519 req_id, directive, ..
1520 } => {
1521 let directive = directive.as_str();
1522 assert!(!directive.contains("project_root:"));
1523 assert!(!directive.contains("work_dir:"));
1524 req_id
1525 }
1526 other => panic!("expected Spawn, got {other:?}"),
1527 };
1528
1529 session
1530 .resolve_pending(
1531 &req_id,
1532 PendingReply::SpawnAck {
1533 value: serde_json::json!({}),
1534 ok: true,
1535 error: None,
1536 stats: None,
1537 },
1538 )
1539 .await;
1540 handle.await.expect("join").expect("execute Ok");
1541 }
1542
1543 #[tokio::test]
1549 async fn execute_splices_task_metadata_from_ctx_meta_runtime() {
1550 use mlua_swarm::Operator;
1551 use tokio::sync::mpsc;
1552
1553 let (tx, mut rx) = mpsc::unbounded_channel();
1554 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1555 SessionId::parse("S-ctxmeta").unwrap(),
1556 tx,
1557 None,
1558 ));
1559
1560 let mut ctx = test_ctx("ST-ctxmeta");
1561 ctx.meta.runtime.insert(
1562 TASK_METADATA_KEY.to_string(),
1563 serde_json::json!({"issue": 20}),
1564 );
1565
1566 let session_bg = session.clone();
1567 let handle = tokio::spawn(async move {
1568 session_bg
1569 .execute(
1570 &ctx,
1571 None,
1572 "".into(),
1573 Some(test_worker_binding()),
1574 test_cap_token(),
1575 )
1576 .await
1577 });
1578
1579 let sent = rx.recv().await.expect("Spawn sent");
1580 let req_id = match sent {
1581 ServerMsg::Spawn {
1582 req_id, directive, ..
1583 } => {
1584 let directive = directive.as_str();
1585 assert!(
1586 directive.contains(r#"task_metadata: {"issue":20}"#),
1587 "directive missing task_metadata splice: {directive}"
1588 );
1589 req_id
1590 }
1591 other => panic!("expected Spawn, got {other:?}"),
1592 };
1593
1594 session
1595 .resolve_pending(
1596 &req_id,
1597 PendingReply::SpawnAck {
1598 value: serde_json::json!({}),
1599 ok: true,
1600 error: None,
1601 stats: None,
1602 },
1603 )
1604 .await;
1605 handle.await.expect("join").expect("execute Ok");
1606 }
1607
1608 #[test]
1614 fn with_task_directive_splices_string_seed_verbatim() {
1615 let directive = default_spawn_directive_with_task_directive(
1616 "impl-lead",
1617 "task-x",
1618 "mse-worker-coder",
1619 &view_with(None, None, None),
1620 None,
1621 None,
1622 None,
1623 &serde_json::json!("do the thing"),
1624 );
1625 let text = directive.as_str();
1626 assert!(
1627 text.contains("task_directive: do the thing"),
1628 "missing task_directive line for a String seed: {text}"
1629 );
1630 }
1631
1632 #[test]
1636 fn with_task_directive_renders_object_seed_as_json_literal() {
1637 let directive = default_spawn_directive_with_task_directive(
1638 "impl-lead",
1639 "task-x",
1640 "mse-worker-coder",
1641 &view_with(None, None, None),
1642 None,
1643 None,
1644 None,
1645 &serde_json::json!({"key": "value"}),
1646 );
1647 let text = directive.as_str();
1648 assert!(
1649 text.contains(r#"task_directive: {"key":"value"}"#),
1650 "missing JSON-literal task_directive line for an Object seed: {text}"
1651 );
1652 }
1653
1654 #[test]
1658 fn with_task_directive_omits_line_when_null() {
1659 let wrapped = default_spawn_directive_with_task_directive(
1660 "impl-lead",
1661 "task-x",
1662 "mse-worker-coder",
1663 &view_with(None, None, None),
1664 None,
1665 None,
1666 None,
1667 &serde_json::Value::Null,
1668 );
1669 let plain = default_spawn_directive(
1670 "impl-lead",
1671 "task-x",
1672 "mse-worker-coder",
1673 &view_with(None, None, None),
1674 None,
1675 None,
1676 None,
1677 );
1678 assert_eq!(
1679 wrapped,
1680 serde_json::Value::String(plain),
1681 "Value::Null seed must not add a task_directive line"
1682 );
1683 }
1684
1685 #[tokio::test]
1692 async fn execute_splices_json_literal_task_directive_for_object_seed() {
1693 use mlua_swarm::Operator;
1694 use tokio::sync::mpsc;
1695
1696 let (tx, mut rx) = mpsc::unbounded_channel();
1697 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1698 SessionId::parse("S-objseed").unwrap(),
1699 tx,
1700 None,
1701 ));
1702
1703 let ctx = test_ctx("ST-objseed");
1704 let rendered_prompt = serde_json::json!({"key": "value"});
1709
1710 let session_bg = session.clone();
1711 let handle = tokio::spawn(async move {
1712 session_bg
1713 .execute(
1714 &ctx,
1715 None,
1716 rendered_prompt,
1717 Some(test_worker_binding()),
1718 test_cap_token(),
1719 )
1720 .await
1721 });
1722
1723 let sent = rx.recv().await.expect("Spawn sent");
1724 let req_id = match sent {
1725 ServerMsg::Spawn {
1726 req_id, directive, ..
1727 } => {
1728 let directive = directive.as_str();
1729 assert!(
1730 directive.contains(r#"task_directive: {"key":"value"}"#),
1731 "directive missing JSON-literal task_directive splice: {directive}"
1732 );
1733 req_id
1734 }
1735 other => panic!("expected Spawn, got {other:?}"),
1736 };
1737
1738 session
1739 .resolve_pending(
1740 &req_id,
1741 PendingReply::SpawnAck {
1742 value: serde_json::json!({}),
1743 ok: true,
1744 error: None,
1745 stats: None,
1746 },
1747 )
1748 .await;
1749 handle.await.expect("join").expect("execute Ok");
1750 }
1751
1752 #[tokio::test]
1758 async fn execute_with_work_dir_appends_ctx_projection_pointer_and_materializes_file() {
1759 use mlua_swarm::Operator;
1760 use tokio::sync::mpsc;
1761
1762 let dir = tempfile::TempDir::new().unwrap();
1763 let mut ctx = test_ctx("ST-proj-1");
1764 ctx.meta.runtime.insert(
1765 TASK_WORK_DIR_KEY.to_string(),
1766 Value::String(dir.path().to_string_lossy().into_owned()),
1767 );
1768
1769 let (tx, mut rx) = mpsc::unbounded_channel();
1770 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1771 SessionId::parse("S-proj-1").unwrap(),
1772 tx,
1773 None,
1774 ));
1775
1776 let session_bg = session.clone();
1777 let handle = tokio::spawn(async move {
1778 session_bg
1779 .execute(
1780 &ctx,
1781 None,
1782 "".into(),
1783 Some(test_worker_binding()),
1784 test_cap_token(),
1785 )
1786 .await
1787 });
1788
1789 let sent = rx.recv().await.expect("Spawn sent");
1790 let req_id = match sent {
1791 ServerMsg::Spawn {
1792 req_id, directive, ..
1793 } => {
1794 assert!(
1795 directive.contains("ctx_projection:"),
1796 "directive missing ctx_projection pointer line: {directive}"
1797 );
1798 assert!(
1805 !directive.contains("ctx_step_dir:"),
1806 "directive must not carry the retired ctx_step_dir line: {directive}"
1807 );
1808 req_id
1809 }
1810 other => panic!("expected Spawn, got {other:?}"),
1811 };
1812
1813 session
1814 .resolve_pending(
1815 &req_id,
1816 PendingReply::SpawnAck {
1817 value: serde_json::json!({}),
1818 ok: true,
1819 error: None,
1820 stats: None,
1821 },
1822 )
1823 .await;
1824 handle.await.expect("join").expect("execute Ok");
1825
1826 let expected_file = dir.path().join("workspace/tasks/ST-proj-1/ctx/_ctx.md");
1827 assert!(
1828 expected_file.exists(),
1829 "materialized projection file missing at {expected_file:?}"
1830 );
1831 }
1832
1833 #[tokio::test]
1838 async fn execute_without_work_dir_spawns_without_ctx_projection_pointer() {
1839 use mlua_swarm::Operator;
1840 use tokio::sync::mpsc;
1841
1842 let (tx, mut rx) = mpsc::unbounded_channel();
1843 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1844 SessionId::parse("S-proj-2").unwrap(),
1845 tx,
1846 None,
1847 ));
1848
1849 let session_bg = session.clone();
1850 let handle = tokio::spawn(async move {
1851 session_bg
1852 .execute(
1853 &test_ctx("ST-proj-2"),
1854 None,
1855 "".into(),
1856 Some(test_worker_binding()),
1857 test_cap_token(),
1858 )
1859 .await
1860 });
1861
1862 let sent = rx.recv().await.expect("Spawn sent");
1863 let req_id = match sent {
1864 ServerMsg::Spawn {
1865 req_id, directive, ..
1866 } => {
1867 assert!(
1868 !directive.contains("ctx_projection:"),
1869 "directive must not carry a pointer line when work_dir is absent \
1870 (fallback): {directive}"
1871 );
1872 req_id
1873 }
1874 other => panic!("expected Spawn, got {other:?}"),
1875 };
1876
1877 session
1878 .resolve_pending(
1879 &req_id,
1880 PendingReply::SpawnAck {
1881 value: serde_json::json!({}),
1882 ok: true,
1883 error: None,
1884 stats: None,
1885 },
1886 )
1887 .await;
1888 handle
1889 .await
1890 .expect("join")
1891 .expect("execute Ok — a materialize skip must not fail the spawn");
1892 }
1893
1894 #[tokio::test]
1905 async fn execute_with_project_root_only_appends_ctx_projection_pointer_default_placement() {
1906 use mlua_swarm::Operator;
1907 use tokio::sync::mpsc;
1908
1909 let dir = tempfile::TempDir::new().unwrap();
1910 let mut ctx = test_ctx("ST-proj-3");
1911 ctx.meta.runtime.insert(
1912 TASK_PROJECT_ROOT_KEY.to_string(),
1913 Value::String(dir.path().to_string_lossy().into_owned()),
1914 );
1915
1916 let (tx, mut rx) = mpsc::unbounded_channel();
1917 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1918 SessionId::parse("S-proj-3").unwrap(),
1919 tx,
1920 None,
1921 ));
1922
1923 let session_bg = session.clone();
1924 let handle = tokio::spawn(async move {
1925 session_bg
1926 .execute(
1927 &ctx,
1928 None,
1929 "".into(),
1930 Some(test_worker_binding()),
1931 test_cap_token(),
1932 )
1933 .await
1934 });
1935
1936 let sent = rx.recv().await.expect("Spawn sent");
1937 let req_id = match sent {
1938 ServerMsg::Spawn {
1939 req_id, directive, ..
1940 } => {
1941 assert!(
1942 directive.contains("ctx_projection:"),
1943 "work_dir absent must still fall back to project_root: {directive}"
1944 );
1945 req_id
1946 }
1947 other => panic!("expected Spawn, got {other:?}"),
1948 };
1949
1950 session
1951 .resolve_pending(
1952 &req_id,
1953 PendingReply::SpawnAck {
1954 value: serde_json::json!({}),
1955 ok: true,
1956 error: None,
1957 stats: None,
1958 },
1959 )
1960 .await;
1961 handle.await.expect("join").expect("execute Ok");
1962
1963 let expected_file = dir.path().join("workspace/tasks/ST-proj-3/ctx/_ctx.md");
1964 assert!(
1965 expected_file.exists(),
1966 "materialized projection file missing at {expected_file:?}"
1967 );
1968 }
1969
1970 #[tokio::test]
1978 async fn execute_with_custom_projection_placement_uses_declared_root_and_template() {
1979 use mlua_swarm::core::projection_placement::{ProjectionPlacement, RootPreference};
1980 use mlua_swarm::Operator;
1981 use tokio::sync::mpsc;
1982
1983 let work_dir = tempfile::TempDir::new().unwrap();
1984 let project_root = tempfile::TempDir::new().unwrap();
1985 let mut ctx = test_ctx("ST-proj-4");
1986 ctx.meta.runtime.insert(
1987 TASK_WORK_DIR_KEY.to_string(),
1988 Value::String(work_dir.path().to_string_lossy().into_owned()),
1989 );
1990 ctx.meta.runtime.insert(
1991 TASK_PROJECT_ROOT_KEY.to_string(),
1992 Value::String(project_root.path().to_string_lossy().into_owned()),
1993 );
1994 let placement = ProjectionPlacement {
1995 root_preference: RootPreference::ProjectRoot,
1996 dir_template: "custom/{task_id}/out".to_string(),
1997 };
1998 ctx.meta.runtime.insert(
1999 PROJECTION_PLACEMENT_KEY.to_string(),
2000 serde_json::to_value(&placement).expect("placement serializes"),
2001 );
2002
2003 let (tx, mut rx) = mpsc::unbounded_channel();
2004 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
2005 SessionId::parse("S-proj-4").unwrap(),
2006 tx,
2007 None,
2008 ));
2009
2010 let session_bg = session.clone();
2011 let handle = tokio::spawn(async move {
2012 session_bg
2013 .execute(
2014 &ctx,
2015 None,
2016 "".into(),
2017 Some(test_worker_binding()),
2018 test_cap_token(),
2019 )
2020 .await
2021 });
2022
2023 let sent = rx.recv().await.expect("Spawn sent");
2024 let req_id = match sent {
2025 ServerMsg::Spawn {
2026 req_id, directive, ..
2027 } => {
2028 assert!(
2029 directive.contains("ctx_projection:"),
2030 "directive missing ctx_projection pointer line: {directive}"
2031 );
2032 req_id
2033 }
2034 other => panic!("expected Spawn, got {other:?}"),
2035 };
2036
2037 session
2038 .resolve_pending(
2039 &req_id,
2040 PendingReply::SpawnAck {
2041 value: serde_json::json!({}),
2042 ok: true,
2043 error: None,
2044 stats: None,
2045 },
2046 )
2047 .await;
2048 handle.await.expect("join").expect("execute Ok");
2049
2050 let expected_file = project_root.path().join("custom/ST-proj-4/out/_ctx.md");
2051 assert!(
2052 expected_file.exists(),
2053 "materialized projection file missing at custom placement target {expected_file:?}"
2054 );
2055 let unexpected_file = work_dir
2056 .path()
2057 .join("workspace/tasks/ST-proj-4/ctx/_ctx.md");
2058 assert!(
2059 !unexpected_file.exists(),
2060 "declared root_preference=ProjectRoot must not fall back to work_dir: {unexpected_file:?}"
2061 );
2062 }
2063}