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> {
421 let mut stats: mlua_swarm::store::trace::WorkerStats = match serde_json::from_value(v.clone()) {
422 Ok(s) => s,
423 Err(e) => {
424 tracing::warn!(
425 error = %e,
426 stats = %v,
427 "spawn_ack: attached stats failed to decode — dropped (the ack itself \
428 still succeeded). Expected an object with optional worker_kind / \
429 model / num_turns / adapter_data plus an optional usage object of \
430 optional input_tokens / output_tokens / total_tokens"
431 );
432 return None;
433 }
434 };
435 if stats.is_empty() {
442 return None;
443 }
444 if stats.worker_kind.is_none() {
445 stats.worker_kind = Some("operator".to_string());
446 }
447 Some(stats)
448}
449
450#[allow(clippy::too_many_arguments)]
508pub(super) fn default_spawn_directive(
509 agent: &str,
510 task_id: &str,
511 subagent_type: &str,
512 view: &AgentContextView,
513 data_sink_endpoint: Option<&str>,
514 base_url: Option<&str>,
515 run_id: Option<&str>,
516) -> String {
517 let context_header = view.to_directive_header();
521 let data_endpoint_block = match data_sink_endpoint {
528 Some(base) => format!(
529 "\n\
530 [Data path endpoint — MainAgent reminder]\n\
531 When you kick a SubAgent, inject the following two lines into\n\
532 its prompt / environment so Big Response payloads (4k+ tokens,\n\
533 files, intermediate artifacts) flow directly to the Store owner,\n\
534 bypassing the MainAgent (context stays small; only the out_id\n\
535 ref is passed around).\n \
536 DATA_EMIT: {base}/v1/data/emit (POST, auth = Bearer worker_handle or ?token=)\n \
537 DATA_GET: {base}/v1/data/<out_id|out_name> (the next SubAgent fetches from $IN_REFS)\n\
538 When a SubAgent produces a Big Response, POST it to DATA_EMIT\n\
539 and return only the one-line out_id ref (do not mix the body\n\
540 in; the MainAgent must not answer directly).\n\
541 \n"
542 ),
543 None => String::new(),
544 };
545 let main_ai_reminder = match view.project_name_alias.as_deref() {
546 Some(a) => format!(
547 "\n\
548 [LDS Session Alias Reminder — MainAI mandatory]\n\
549 Before kicking the SubAgent below, call:\n \
550 mcp__lds__session_create(root=<working_dir>, alias=\"{a}\")\n\
551 (= establish a single task-level lds session; reuse on repeated dispatch).\n\
552 Then add this literal line to the SubAgent prompt body below:\n \
553 LDS Session Alias: {a}\n\
554 The SubAgent will call mcp__lds__session_start(alias=\"{a}\") on init,\n\
555 keeping worktree ownership unified across dispatches.\n\
556 (Full discipline rationale is inlined above; reach is via this directive itself,\n\
557 not via any external doc path. The 2 steps above are the complete contract.)\n\
558 \n"
559 ),
560 None => String::new(),
561 };
562 let base_url_line = match base_url {
566 Some(u) => u.to_string(),
567 None => "<your server's actual bind — check with mse_doctor>".to_string(),
568 };
569 let run_route_line = match run_id {
574 Some(rid) => format!("GET <base_url>/v1/runs/{rid}"),
575 None => "GET <base_url>/v1/runs/<run_id>".to_string(),
576 };
577 format!(
578 "[agent_primitive dispatch=@{agent}]\n\
579 worker endpoint:\n \
580 GET <base_url>/v1/worker/prompt?task_id={task_id}\n \
581 POST <base_url>/v1/worker/submit\n\
582 auth: Bearer <worker_handle from THIS Spawn payload (= short `wh-XXXXXXXX` form)>\n\
583 task_id: {task_id}\n\
584 agent_id: {agent}\n\
585 {context_header}\
586 {data_endpoint_block}\
587 {main_ai_reminder}\
588 Kick a SubAgent via Agent tool with subagent_type=\"{subagent_type}\" (= project-local \
589 `.claude/agents/{subagent_type}.md`, this agent's Blueprint-declared worker binding). \
590 The prompt you pass to it MUST be EXACTLY these 4 lines (no preamble, no extra text):\n\
591 \n \
592 agent_id: {agent}\n \
593 worker_handle: <THIS Spawn payload's `worker_handle` field (short string `wh-XXXXXXXX`)>\n \
594 base_url: {base_url_line}\n \
595 task_id: {task_id}\n\
596 \n\
597 The SubAgent self-fetches system + prompt via GET (Bearer = handle), \
598 executes as agent @{agent}, POSTs raw body to /v1/worker/submit (Bearer = handle, \
599 server resolves task_id from handle), and replies `OUTPUT` 1 word. You then forward \
600 SpawnAck {{req_id, value:{{}}, ok:true}} through your operator client — MCP path: \
601 mse_ack(sid, req_id, kind=\"spawn_ack\", ok=true) (= empty value because canonical \
602 body lives in output_tail via the POST). \
603 Do NOT fetch /v1/worker/prompt yourself. Do NOT wrap, summarize, or field-select \
604 the SubAgent reply. Observation / debug is a separate channel (= agent-inspect MCP / \
605 {run_route_line}), do NOT mix it into the forward path. \
606 If the SubAgent type is not registered, FAIL LOUD: reply SpawnAck ok=false with an \
607 error explaining the missing `.claude/agents/{subagent_type}.md` — do NOT fall back \
608 to another subagent_type."
609 )
610}
611
612#[allow(clippy::too_many_arguments)]
626pub(super) fn default_spawn_directive_with_task_directive(
627 agent: &str,
628 task_id: &str,
629 subagent_type: &str,
630 view: &AgentContextView,
631 data_sink_endpoint: Option<&str>,
632 base_url: Option<&str>,
633 run_id: Option<&str>,
634 task_directive: &Value,
635) -> String {
636 let base = default_spawn_directive(
637 agent,
638 task_id,
639 subagent_type,
640 view,
641 data_sink_endpoint,
642 base_url,
643 run_id,
644 );
645 let task_directive_line = match task_directive {
650 Value::Null => String::new(),
651 Value::String(s) => format!("task_directive: {s}\n"),
652 other => format!("task_directive: {other}\n"),
653 };
654 format!("{base}{task_directive_line}")
655}
656
657fn append_projection_pointer(
692 directive: String,
693 task_id: &StepId,
694 view: &AgentContextView,
695 run_id: Option<&str>,
696 placement: &ProjectionPlacement,
697) -> String {
698 let Some(root) = placement.resolve_root(view) else {
699 return directive;
700 };
701 match serde_json::to_value(view) {
702 Ok(ctx_data) => {
703 let key = ProjectionKey {
704 task_id: task_id.to_string(),
705 run_id: run_id.map(str::to_string),
706 step: None,
707 path: None,
708 };
709 let adapter = FileProjectionAdapter::with_placement(root, placement.clone());
710 match adapter.project(&key, &ctx_data) {
711 Ok(reference) => {
712 let pointer_value = match &reference {
713 ProjectionRef::File { path } => serde_json::json!({ "file": path }),
714 ProjectionRef::Query { endpoint, key } => {
715 serde_json::json!({ "endpoint": endpoint, "key": key })
716 }
717 };
718 format!("{directive}ctx_projection: {pointer_value}\n")
719 }
720 Err(err) => {
721 tracing::warn!(
722 %task_id,
723 error = %err,
724 "projection hook: materialize failed, spawning without a pointer"
725 );
726 directive
727 }
728 }
729 }
730 Err(err) => {
731 tracing::warn!(
732 %task_id,
733 error = %err,
734 "projection hook: AgentContextView serialize failed, spawning without a pointer"
735 );
736 directive
737 }
738 }
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744 use mlua_swarm::core::agent_context::{
745 TASK_METADATA_KEY, TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY,
746 };
747
748 fn view_with(
755 alias: Option<&str>,
756 project_root: Option<&str>,
757 work_dir: Option<&str>,
758 ) -> AgentContextView {
759 AgentContextView {
760 project_name_alias: alias.map(String::from),
761 project_root: project_root.map(String::from),
762 work_dir: work_dir.map(String::from),
763 ..AgentContextView::default()
764 }
765 }
766
767 #[test]
768 fn ack_stats_survive_a_total_only_operator_report() {
769 let stats = decode_ack_stats(serde_json::json!({
775 "usage": {"total_tokens": 198471},
776 "model": "opus",
777 "num_turns": 22,
778 }))
779 .expect("a total-only report must land on the StepEntry");
780 assert_eq!(stats.usage.expect("usage").total_tokens, 198471);
781 assert_eq!(stats.model.as_deref(), Some("opus"));
782 assert_eq!(stats.num_turns, Some(22));
783 assert_eq!(
784 stats.worker_kind.as_deref(),
785 Some("operator"),
786 "the ack axis labels itself"
787 );
788 }
789
790 #[test]
791 fn ack_stats_of_an_undecodable_shape_are_dropped_not_fatal() {
792 assert!(decode_ack_stats(serde_json::json!("not-an-object")).is_none());
795 assert!(
796 decode_ack_stats(serde_json::json!({})).is_none(),
797 "an all-empty stats object records nothing"
798 );
799 }
800
801 #[tokio::test]
802 async fn connection_state_tracks_the_current_sender() {
803 let (tx, _rx) = mpsc::unbounded_channel();
804 let session = WSOperatorSession::new_with_base_url(
805 SessionId::parse("S-connection-state").unwrap(),
806 tx.clone(),
807 None,
808 );
809 assert!(session.is_connected().await);
810
811 session.clear_tx_if(&tx).await;
812 assert!(!session.is_connected().await);
813 }
814
815 #[tokio::test]
816 async fn stale_disconnect_does_not_clear_a_reconnected_sender() {
817 let (old_tx, _old_rx) = mpsc::unbounded_channel();
818 let (new_tx, _new_rx) = mpsc::unbounded_channel();
819 let session = WSOperatorSession::new_with_base_url(
820 SessionId::parse("S-reconnect-state").unwrap(),
821 old_tx.clone(),
822 None,
823 );
824
825 session.replace_tx(new_tx).await;
826 session.clear_tx_if(&old_tx).await;
827
828 assert!(session.is_connected().await);
829 }
830
831 #[test]
832 fn directive_omits_project_name_alias_when_none() {
833 let d = default_spawn_directive(
834 "implementer",
835 "task-x",
836 "code-worker",
837 &view_with(None, None, None),
838 None,
839 None,
840 None,
841 );
842 assert!(!d.contains("project_name_alias:"));
843 assert!(!d.contains("LDS Session Alias"));
844 assert!(!d.contains("session_create"));
845 }
846
847 #[test]
848 fn directive_emits_project_name_alias_when_some() {
849 let d = default_spawn_directive(
850 "implementer",
851 "task-x",
852 "code-worker",
853 &view_with(Some("mse-task-7785"), None, None),
854 None,
855 None,
856 None,
857 );
858 assert!(
860 d.contains("project_name_alias: mse-task-7785"),
861 "directive missing project_name_alias header: {d}"
862 );
863 assert!(
865 d.contains("mcp__lds__session_create(root=<working_dir>, alias=\"mse-task-7785\")"),
866 "directive missing session_create reminder: {d}"
867 );
868 assert!(
869 d.contains("LDS Session Alias: mse-task-7785"),
870 "directive missing SubAgent prompt inject line: {d}"
871 );
872 assert!(
874 d.contains("inlined above") || d.contains("complete contract"),
875 "directive should inline rationale rather than point at external doc: {d}"
876 );
877 let forbidden_doc_ref = format!(".{}/CLAUDE.md", "claude");
889 assert!(
890 !d.contains(&forbidden_doc_ref),
891 "directive must not reference {forbidden_doc_ref} (out of MainAI scope): {d}"
892 );
893 }
894
895 #[test]
896 fn directive_omits_data_endpoint_when_none() {
897 let d = default_spawn_directive(
898 "implementer",
899 "task-x",
900 "code-worker",
901 &view_with(None, None, None),
902 None,
903 None,
904 None,
905 );
906 assert!(!d.contains("[Data path endpoint"));
907 assert!(!d.contains("DATA_EMIT"));
908 assert!(!d.contains("DATA_GET"));
909 }
910
911 #[test]
912 fn directive_emits_data_endpoint_when_some() {
913 let base = "http://127.0.0.1:7785";
914 let d = default_spawn_directive(
915 "implementer",
916 "task-x",
917 "code-worker",
918 &view_with(None, None, None),
919 Some(base),
920 None,
921 None,
922 );
923 assert!(
924 d.contains("[Data path endpoint"),
925 "directive missing data endpoint block header: {d}"
926 );
927 assert!(
928 d.contains(&format!("DATA_EMIT: {base}/v1/data/emit")),
929 "directive missing single-mouth emit line: {d}"
930 );
931 assert!(
932 d.contains("Bearer worker_handle or ?token="),
933 "directive missing auth transport hint: {d}"
934 );
935 assert!(
936 d.contains(&format!("DATA_GET: {base}/v1/data/<out_id|out_name>")),
937 "directive missing GET line: {d}"
938 );
939 assert!(
940 !d.contains("emit-auth"),
941 "old split endpoint must not leak into directive: {d}"
942 );
943 assert!(
944 d.contains("bypassing the MainAgent") && d.contains("out_id ref"),
945 "directive should carry the ownership + bypass reasoning: {d}"
946 );
947 }
948
949 #[test]
950 fn directive_carries_declared_subagent_type_and_has_no_fallback() {
951 let d = default_spawn_directive(
952 "implementer",
953 "task-x",
954 "code-worker",
955 &view_with(None, None, None),
956 None,
957 None,
958 None,
959 );
960 assert!(
961 d.contains("subagent_type=\"code-worker\""),
962 "directive must carry the Blueprint-declared subagent_type literally: {d}"
963 );
964 assert!(
965 d.contains(".claude/agents/code-worker.md"),
966 "directive must reference the declared subagent's own .md path: {d}"
967 );
968 assert!(
970 !d.contains("general-purpose"),
971 "directive must not fall back to subagent_type=\"general-purpose\": {d}"
972 );
973 assert!(
974 !d.contains("mse-worker\""),
975 "directive must not carry the old hardcoded \"mse-worker\" literal: {d}"
976 );
977 assert!(
978 d.contains("FAIL LOUD"),
979 "directive must instruct the MainAI to fail loud instead of falling back: {d}"
980 );
981 }
982
983 #[test]
989 fn directive_renders_actual_base_url_when_some() {
990 let d = default_spawn_directive(
991 "implementer",
992 "task-x",
993 "code-worker",
994 &view_with(None, None, None),
995 None,
996 Some("http://127.0.0.1:8888"),
997 None,
998 );
999 assert!(
1000 d.contains("base_url: http://127.0.0.1:8888"),
1001 "directive must render the actual bind literally: {d}"
1002 );
1003 assert!(
1004 !d.contains("mse_doctor"),
1005 "no mse_doctor detour when bind is known: {d}"
1006 );
1007 }
1008
1009 #[test]
1013 fn directive_falls_back_to_mse_doctor_pointer_when_none() {
1014 let d = default_spawn_directive(
1015 "implementer",
1016 "task-x",
1017 "code-worker",
1018 &view_with(None, None, None),
1019 None,
1020 None,
1021 None,
1022 );
1023 assert!(
1024 d.contains("check with mse_doctor"),
1025 "fallback must point at mse_doctor: {d}"
1026 );
1027 }
1028
1029 #[test]
1033 fn directive_never_contains_stale_example_port_7786() {
1034 for base in [
1035 None,
1036 Some("http://127.0.0.1:7777"),
1037 Some("http://192.0.2.1:9000"),
1038 ] {
1039 let d = default_spawn_directive(
1040 "implementer",
1041 "task-x",
1042 "code-worker",
1043 &view_with(Some("mse-task-alias"), None, None),
1044 Some("http://127.0.0.1:7785"),
1045 base,
1046 None,
1047 );
1048 assert!(
1049 !d.contains("7786"),
1050 "stale example port 7786 leaked: base={base:?}, d={d}"
1051 );
1052 }
1053 }
1054
1055 #[test]
1061 fn directive_never_contains_stale_tasks_id_route() {
1062 let d = default_spawn_directive(
1063 "implementer",
1064 "task-x",
1065 "code-worker",
1066 &view_with(None, None, None),
1067 None,
1068 None,
1069 Some("R-abc123"),
1070 );
1071 assert!(
1072 !d.contains("/v1/tasks/{id}") && !d.contains("/v1/tasks/{{id}}"),
1073 "stale /v1/tasks/{{id}} observation hint leaked: {d}"
1074 );
1075 }
1076
1077 #[test]
1080 fn directive_renders_actual_run_id_when_some() {
1081 let d = default_spawn_directive(
1082 "implementer",
1083 "task-x",
1084 "code-worker",
1085 &view_with(None, None, None),
1086 None,
1087 None,
1088 Some("R-abc123"),
1089 );
1090 assert!(
1091 d.contains("GET <base_url>/v1/runs/R-abc123"),
1092 "directive missing real run_id in observation route: {d}"
1093 );
1094 }
1095
1096 #[test]
1099 fn directive_falls_back_to_run_id_placeholder_when_none() {
1100 let d = default_spawn_directive(
1101 "implementer",
1102 "task-x",
1103 "code-worker",
1104 &view_with(None, None, None),
1105 None,
1106 None,
1107 None,
1108 );
1109 assert!(
1110 d.contains("GET <base_url>/v1/runs/<run_id>"),
1111 "directive missing placeholder observation route: {d}"
1112 );
1113 }
1114
1115 #[test]
1120 fn directive_omits_project_root_and_work_dir_when_both_none() {
1121 let d = default_spawn_directive(
1122 "implementer",
1123 "task-x",
1124 "code-worker",
1125 &view_with(None, None, None),
1126 None,
1127 None,
1128 None,
1129 );
1130 assert!(!d.contains("project_root:"));
1131 assert!(!d.contains("work_dir:"));
1132 }
1133
1134 #[test]
1137 fn directive_splices_project_root_and_work_dir_when_both_present() {
1138 let d = default_spawn_directive(
1139 "implementer",
1140 "task-x",
1141 "code-worker",
1142 &view_with(None, Some("/repo"), Some("/repo/work")),
1143 None,
1144 None,
1145 None,
1146 );
1147 assert!(
1148 d.contains("project_root: /repo"),
1149 "directive missing project_root header: {d}"
1150 );
1151 assert!(
1152 d.contains("work_dir: /repo/work"),
1153 "directive missing work_dir header: {d}"
1154 );
1155 }
1156
1157 #[test]
1160 fn directive_splices_project_root_only_when_work_dir_absent() {
1161 let d = default_spawn_directive(
1162 "implementer",
1163 "task-x",
1164 "code-worker",
1165 &view_with(None, Some("/repo"), None),
1166 None,
1167 None,
1168 None,
1169 );
1170 assert!(
1171 d.contains("project_root: /repo"),
1172 "directive missing project_root header: {d}"
1173 );
1174 assert!(!d.contains("work_dir:"));
1175 }
1176
1177 #[test]
1184 fn directive_splices_task_metadata_when_some() {
1185 let view = AgentContextView {
1186 task_metadata: Some(serde_json::json!({"issue": 20})),
1187 ..view_with(None, Some("/repo"), None)
1188 };
1189 let d = default_spawn_directive(
1190 "implementer",
1191 "task-x",
1192 "code-worker",
1193 &view,
1194 None,
1195 None,
1196 None,
1197 );
1198 assert!(
1199 d.contains(r#"task_metadata: {"issue":20}"#),
1200 "directive missing task_metadata header: {d}"
1201 );
1202 assert!(d.contains("project_root: /repo"));
1204 }
1205
1206 #[test]
1210 fn directive_omits_task_metadata_when_none() {
1211 let d = default_spawn_directive(
1212 "implementer",
1213 "task-x",
1214 "code-worker",
1215 &view_with(None, None, None),
1216 None,
1217 None,
1218 None,
1219 );
1220 assert!(!d.contains("task_metadata:"));
1221 }
1222
1223 fn test_ctx(task_id: &str) -> mlua_swarm::Ctx {
1226 mlua_swarm::Ctx::new(mlua_swarm::StepId::parse(task_id).unwrap(), 1, "a")
1227 }
1228
1229 fn test_worker_binding() -> mlua_swarm::WorkerBinding {
1230 mlua_swarm::WorkerBinding {
1231 variant: "test-variant".into(),
1232 tools: vec![],
1233 request_digest: None,
1234 requested_model: None,
1235 }
1236 }
1237
1238 fn test_cap_token() -> mlua_swarm::CapToken {
1239 mlua_swarm::CapToken {
1240 agent_id: "a".into(),
1241 role: mlua_swarm::Role::Worker,
1242 scopes: vec!["*".into()],
1243 issued_at: 0,
1244 expire_at: u64::MAX / 2,
1245 max_uses: None,
1246 nonce: "test-nonce".into(),
1247 sig_hex: "".into(),
1248 }
1249 }
1250
1251 #[tokio::test]
1257 async fn spawn_halt_reply_lands_as_ok_worker_result_with_marker() {
1258 use mlua_swarm::Operator;
1259 use tokio::sync::mpsc;
1260
1261 let (tx, mut rx) = mpsc::unbounded_channel();
1262 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1263 SessionId::parse("S-halt").unwrap(),
1264 tx,
1265 None,
1266 ));
1267
1268 let session_bg = session.clone();
1271 let handle = tokio::spawn(async move {
1272 session_bg
1273 .execute(
1274 &test_ctx("ST-halt"),
1275 None,
1276 "".into(),
1277 Some(test_worker_binding()),
1278 test_cap_token(),
1279 )
1280 .await
1281 });
1282
1283 let sent = rx.recv().await.expect("Spawn sent");
1284 let req_id = match sent {
1285 ServerMsg::Spawn { req_id, .. } => req_id,
1286 other => panic!("expected Spawn, got {other:?}"),
1287 };
1288
1289 session
1290 .resolve_pending(
1291 &req_id,
1292 PendingReply::SpawnHalt {
1293 value: serde_json::json!({"partial": "abc"}),
1294 reason: Some("shape verified".into()),
1295 },
1296 )
1297 .await;
1298
1299 let result = handle.await.expect("join").expect("execute Ok");
1300 assert!(
1301 result.ok,
1302 "spawn_halt must land as ok=true (normal termination), got: {result:?}"
1303 );
1304 assert_eq!(result.value["halted"], true);
1305 assert_eq!(result.value["reason"], "shape verified");
1306 assert_eq!(result.value["value"], serde_json::json!({"partial": "abc"}));
1307 }
1308
1309 #[tokio::test]
1312 async fn spawn_ack_with_error_still_lands_as_worker_error() {
1313 use mlua_swarm::{Operator, WorkerError};
1314 use tokio::sync::mpsc;
1315
1316 let (tx, mut rx) = mpsc::unbounded_channel();
1317 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1318 SessionId::parse("S-err").unwrap(),
1319 tx,
1320 None,
1321 ));
1322
1323 let session_bg = session.clone();
1324 let handle = tokio::spawn(async move {
1325 session_bg
1326 .execute(
1327 &test_ctx("ST-err"),
1328 None,
1329 "".into(),
1330 Some(test_worker_binding()),
1331 test_cap_token(),
1332 )
1333 .await
1334 });
1335
1336 let sent = rx.recv().await.expect("Spawn sent");
1337 let req_id = match sent {
1338 ServerMsg::Spawn { req_id, .. } => req_id,
1339 other => panic!("expected Spawn, got {other:?}"),
1340 };
1341
1342 session
1343 .resolve_pending(
1344 &req_id,
1345 PendingReply::SpawnAck {
1346 value: serde_json::json!({}),
1347 ok: false,
1348 error: Some("real crash".into()),
1349 stats: None,
1350 },
1351 )
1352 .await;
1353
1354 let err = handle.await.expect("join").expect_err("must be error");
1355 assert!(matches!(err, WorkerError::Failed(msg) if msg.contains("real crash")));
1356 }
1357
1358 #[tokio::test]
1364 async fn fail_pending_unblocks_a_parked_spawn_with_worker_error() {
1365 use mlua_swarm::{Operator, WorkerError};
1366 use tokio::sync::mpsc;
1367
1368 let (tx, mut rx) = mpsc::unbounded_channel();
1369 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1370 SessionId::parse("S-teardown").unwrap(),
1371 tx,
1372 None,
1373 ));
1374
1375 let session_bg = session.clone();
1376 let handle = tokio::spawn(async move {
1377 session_bg
1378 .execute(
1379 &test_ctx("ST-teardown"),
1380 None,
1381 "".into(),
1382 Some(test_worker_binding()),
1383 test_cap_token(),
1384 )
1385 .await
1386 });
1387
1388 let _sent = rx.recv().await.expect("Spawn sent");
1392
1393 session.fail_pending("operator session torn down").await;
1394
1395 let err = handle
1396 .await
1397 .expect("join")
1398 .expect_err("a parked spawn must fail once pending is drained");
1399 assert!(
1400 matches!(err, WorkerError::Failed(_)),
1401 "fail_pending must surface a WorkerError::Failed, got: {err:?}"
1402 );
1403 }
1404
1405 #[tokio::test]
1412 async fn execute_splices_project_root_and_work_dir_from_ctx_meta_runtime() {
1413 use mlua_swarm::Operator;
1414 use tokio::sync::mpsc;
1415
1416 let (tx, mut rx) = mpsc::unbounded_channel();
1417 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1418 SessionId::parse("S-ctxroot").unwrap(),
1419 tx,
1420 None,
1421 ));
1422
1423 let mut ctx = test_ctx("ST-ctxroot");
1424 ctx.meta.runtime.insert(
1425 TASK_PROJECT_ROOT_KEY.to_string(),
1426 serde_json::json!("/repo"),
1427 );
1428 ctx.meta.runtime.insert(
1429 TASK_WORK_DIR_KEY.to_string(),
1430 serde_json::json!("/repo/work"),
1431 );
1432
1433 let session_bg = session.clone();
1434 let handle = tokio::spawn(async move {
1435 session_bg
1436 .execute(
1437 &ctx,
1438 None,
1439 "".into(),
1440 Some(test_worker_binding()),
1441 test_cap_token(),
1442 )
1443 .await
1444 });
1445
1446 let sent = rx.recv().await.expect("Spawn sent");
1447 let req_id = match sent {
1448 ServerMsg::Spawn {
1449 req_id, directive, ..
1450 } => {
1451 let directive = directive.as_str();
1455 assert!(
1456 directive.contains("project_root: /repo"),
1457 "directive missing project_root splice: {directive}"
1458 );
1459 assert!(
1460 directive.contains("work_dir: /repo/work"),
1461 "directive missing work_dir splice: {directive}"
1462 );
1463 req_id
1464 }
1465 other => panic!("expected Spawn, got {other:?}"),
1466 };
1467
1468 session
1469 .resolve_pending(
1470 &req_id,
1471 PendingReply::SpawnAck {
1472 value: serde_json::json!({}),
1473 ok: true,
1474 error: None,
1475 stats: None,
1476 },
1477 )
1478 .await;
1479 handle.await.expect("join").expect("execute Ok");
1480 }
1481
1482 #[tokio::test]
1487 async fn execute_splices_project_root_only_when_ctx_meta_runtime_partial() {
1488 use mlua_swarm::Operator;
1489 use tokio::sync::mpsc;
1490
1491 let (tx, mut rx) = mpsc::unbounded_channel();
1492 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1493 SessionId::parse("S-ctxpartial").unwrap(),
1494 tx,
1495 None,
1496 ));
1497
1498 let mut ctx = test_ctx("ST-ctxpartial");
1499 ctx.meta.runtime.insert(
1500 TASK_PROJECT_ROOT_KEY.to_string(),
1501 serde_json::json!("/repo"),
1502 );
1503
1504 let session_bg = session.clone();
1505 let handle = tokio::spawn(async move {
1506 session_bg
1507 .execute(
1508 &ctx,
1509 None,
1510 "".into(),
1511 Some(test_worker_binding()),
1512 test_cap_token(),
1513 )
1514 .await
1515 });
1516
1517 let sent = rx.recv().await.expect("Spawn sent");
1518 let req_id = match sent {
1519 ServerMsg::Spawn {
1520 req_id, directive, ..
1521 } => {
1522 let directive = directive.as_str();
1523 assert!(
1524 directive.contains("project_root: /repo"),
1525 "directive missing project_root splice: {directive}"
1526 );
1527 assert!(!directive.contains("work_dir:"));
1528 req_id
1529 }
1530 other => panic!("expected Spawn, got {other:?}"),
1531 };
1532
1533 session
1534 .resolve_pending(
1535 &req_id,
1536 PendingReply::SpawnAck {
1537 value: serde_json::json!({}),
1538 ok: true,
1539 error: None,
1540 stats: None,
1541 },
1542 )
1543 .await;
1544 handle.await.expect("join").expect("execute Ok");
1545 }
1546
1547 #[tokio::test]
1551 async fn execute_omits_project_root_and_work_dir_when_ctx_meta_runtime_absent() {
1552 use mlua_swarm::Operator;
1553 use tokio::sync::mpsc;
1554
1555 let (tx, mut rx) = mpsc::unbounded_channel();
1556 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1557 SessionId::parse("S-ctxabsent").unwrap(),
1558 tx,
1559 None,
1560 ));
1561
1562 let ctx = test_ctx("ST-ctxabsent");
1563
1564 let session_bg = session.clone();
1565 let handle = tokio::spawn(async move {
1566 session_bg
1567 .execute(
1568 &ctx,
1569 None,
1570 "".into(),
1571 Some(test_worker_binding()),
1572 test_cap_token(),
1573 )
1574 .await
1575 });
1576
1577 let sent = rx.recv().await.expect("Spawn sent");
1578 let req_id = match sent {
1579 ServerMsg::Spawn {
1580 req_id, directive, ..
1581 } => {
1582 let directive = directive.as_str();
1583 assert!(!directive.contains("project_root:"));
1584 assert!(!directive.contains("work_dir:"));
1585 req_id
1586 }
1587 other => panic!("expected Spawn, got {other:?}"),
1588 };
1589
1590 session
1591 .resolve_pending(
1592 &req_id,
1593 PendingReply::SpawnAck {
1594 value: serde_json::json!({}),
1595 ok: true,
1596 error: None,
1597 stats: None,
1598 },
1599 )
1600 .await;
1601 handle.await.expect("join").expect("execute Ok");
1602 }
1603
1604 #[tokio::test]
1610 async fn execute_splices_task_metadata_from_ctx_meta_runtime() {
1611 use mlua_swarm::Operator;
1612 use tokio::sync::mpsc;
1613
1614 let (tx, mut rx) = mpsc::unbounded_channel();
1615 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1616 SessionId::parse("S-ctxmeta").unwrap(),
1617 tx,
1618 None,
1619 ));
1620
1621 let mut ctx = test_ctx("ST-ctxmeta");
1622 ctx.meta.runtime.insert(
1623 TASK_METADATA_KEY.to_string(),
1624 serde_json::json!({"issue": 20}),
1625 );
1626
1627 let session_bg = session.clone();
1628 let handle = tokio::spawn(async move {
1629 session_bg
1630 .execute(
1631 &ctx,
1632 None,
1633 "".into(),
1634 Some(test_worker_binding()),
1635 test_cap_token(),
1636 )
1637 .await
1638 });
1639
1640 let sent = rx.recv().await.expect("Spawn sent");
1641 let req_id = match sent {
1642 ServerMsg::Spawn {
1643 req_id, directive, ..
1644 } => {
1645 let directive = directive.as_str();
1646 assert!(
1647 directive.contains(r#"task_metadata: {"issue":20}"#),
1648 "directive missing task_metadata splice: {directive}"
1649 );
1650 req_id
1651 }
1652 other => panic!("expected Spawn, got {other:?}"),
1653 };
1654
1655 session
1656 .resolve_pending(
1657 &req_id,
1658 PendingReply::SpawnAck {
1659 value: serde_json::json!({}),
1660 ok: true,
1661 error: None,
1662 stats: None,
1663 },
1664 )
1665 .await;
1666 handle.await.expect("join").expect("execute Ok");
1667 }
1668
1669 #[test]
1675 fn with_task_directive_splices_string_seed_verbatim() {
1676 let directive = default_spawn_directive_with_task_directive(
1677 "implementer",
1678 "task-x",
1679 "code-worker",
1680 &view_with(None, None, None),
1681 None,
1682 None,
1683 None,
1684 &serde_json::json!("do the thing"),
1685 );
1686 let text = directive.as_str();
1687 assert!(
1688 text.contains("task_directive: do the thing"),
1689 "missing task_directive line for a String seed: {text}"
1690 );
1691 }
1692
1693 #[test]
1697 fn with_task_directive_renders_object_seed_as_json_literal() {
1698 let directive = default_spawn_directive_with_task_directive(
1699 "implementer",
1700 "task-x",
1701 "code-worker",
1702 &view_with(None, None, None),
1703 None,
1704 None,
1705 None,
1706 &serde_json::json!({"key": "value"}),
1707 );
1708 let text = directive.as_str();
1709 assert!(
1710 text.contains(r#"task_directive: {"key":"value"}"#),
1711 "missing JSON-literal task_directive line for an Object seed: {text}"
1712 );
1713 }
1714
1715 #[test]
1719 fn with_task_directive_omits_line_when_null() {
1720 let wrapped = default_spawn_directive_with_task_directive(
1721 "implementer",
1722 "task-x",
1723 "code-worker",
1724 &view_with(None, None, None),
1725 None,
1726 None,
1727 None,
1728 &serde_json::Value::Null,
1729 );
1730 let plain = default_spawn_directive(
1731 "implementer",
1732 "task-x",
1733 "code-worker",
1734 &view_with(None, None, None),
1735 None,
1736 None,
1737 None,
1738 );
1739 assert_eq!(
1740 wrapped,
1741 serde_json::Value::String(plain),
1742 "Value::Null seed must not add a task_directive line"
1743 );
1744 }
1745
1746 #[tokio::test]
1753 async fn execute_splices_json_literal_task_directive_for_object_seed() {
1754 use mlua_swarm::Operator;
1755 use tokio::sync::mpsc;
1756
1757 let (tx, mut rx) = mpsc::unbounded_channel();
1758 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1759 SessionId::parse("S-objseed").unwrap(),
1760 tx,
1761 None,
1762 ));
1763
1764 let ctx = test_ctx("ST-objseed");
1765 let rendered_prompt = serde_json::json!({"key": "value"});
1770
1771 let session_bg = session.clone();
1772 let handle = tokio::spawn(async move {
1773 session_bg
1774 .execute(
1775 &ctx,
1776 None,
1777 rendered_prompt,
1778 Some(test_worker_binding()),
1779 test_cap_token(),
1780 )
1781 .await
1782 });
1783
1784 let sent = rx.recv().await.expect("Spawn sent");
1785 let req_id = match sent {
1786 ServerMsg::Spawn {
1787 req_id, directive, ..
1788 } => {
1789 let directive = directive.as_str();
1790 assert!(
1791 directive.contains(r#"task_directive: {"key":"value"}"#),
1792 "directive missing JSON-literal task_directive splice: {directive}"
1793 );
1794 req_id
1795 }
1796 other => panic!("expected Spawn, got {other:?}"),
1797 };
1798
1799 session
1800 .resolve_pending(
1801 &req_id,
1802 PendingReply::SpawnAck {
1803 value: serde_json::json!({}),
1804 ok: true,
1805 error: None,
1806 stats: None,
1807 },
1808 )
1809 .await;
1810 handle.await.expect("join").expect("execute Ok");
1811 }
1812
1813 #[tokio::test]
1819 async fn execute_with_work_dir_appends_ctx_projection_pointer_and_materializes_file() {
1820 use mlua_swarm::Operator;
1821 use tokio::sync::mpsc;
1822
1823 let dir = tempfile::TempDir::new().unwrap();
1824 let mut ctx = test_ctx("ST-proj-1");
1825 ctx.meta.runtime.insert(
1826 TASK_WORK_DIR_KEY.to_string(),
1827 Value::String(dir.path().to_string_lossy().into_owned()),
1828 );
1829
1830 let (tx, mut rx) = mpsc::unbounded_channel();
1831 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1832 SessionId::parse("S-proj-1").unwrap(),
1833 tx,
1834 None,
1835 ));
1836
1837 let session_bg = session.clone();
1838 let handle = tokio::spawn(async move {
1839 session_bg
1840 .execute(
1841 &ctx,
1842 None,
1843 "".into(),
1844 Some(test_worker_binding()),
1845 test_cap_token(),
1846 )
1847 .await
1848 });
1849
1850 let sent = rx.recv().await.expect("Spawn sent");
1851 let req_id = match sent {
1852 ServerMsg::Spawn {
1853 req_id, directive, ..
1854 } => {
1855 assert!(
1856 directive.contains("ctx_projection:"),
1857 "directive missing ctx_projection pointer line: {directive}"
1858 );
1859 assert!(
1866 !directive.contains("ctx_step_dir:"),
1867 "directive must not carry the retired ctx_step_dir line: {directive}"
1868 );
1869 req_id
1870 }
1871 other => panic!("expected Spawn, got {other:?}"),
1872 };
1873
1874 session
1875 .resolve_pending(
1876 &req_id,
1877 PendingReply::SpawnAck {
1878 value: serde_json::json!({}),
1879 ok: true,
1880 error: None,
1881 stats: None,
1882 },
1883 )
1884 .await;
1885 handle.await.expect("join").expect("execute Ok");
1886
1887 let expected_file = dir.path().join("workspace/tasks/ST-proj-1/ctx/_ctx.md");
1888 assert!(
1889 expected_file.exists(),
1890 "materialized projection file missing at {expected_file:?}"
1891 );
1892 }
1893
1894 #[tokio::test]
1899 async fn execute_without_work_dir_spawns_without_ctx_projection_pointer() {
1900 use mlua_swarm::Operator;
1901 use tokio::sync::mpsc;
1902
1903 let (tx, mut rx) = mpsc::unbounded_channel();
1904 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1905 SessionId::parse("S-proj-2").unwrap(),
1906 tx,
1907 None,
1908 ));
1909
1910 let session_bg = session.clone();
1911 let handle = tokio::spawn(async move {
1912 session_bg
1913 .execute(
1914 &test_ctx("ST-proj-2"),
1915 None,
1916 "".into(),
1917 Some(test_worker_binding()),
1918 test_cap_token(),
1919 )
1920 .await
1921 });
1922
1923 let sent = rx.recv().await.expect("Spawn sent");
1924 let req_id = match sent {
1925 ServerMsg::Spawn {
1926 req_id, directive, ..
1927 } => {
1928 assert!(
1929 !directive.contains("ctx_projection:"),
1930 "directive must not carry a pointer line when work_dir is absent \
1931 (fallback): {directive}"
1932 );
1933 req_id
1934 }
1935 other => panic!("expected Spawn, got {other:?}"),
1936 };
1937
1938 session
1939 .resolve_pending(
1940 &req_id,
1941 PendingReply::SpawnAck {
1942 value: serde_json::json!({}),
1943 ok: true,
1944 error: None,
1945 stats: None,
1946 },
1947 )
1948 .await;
1949 handle
1950 .await
1951 .expect("join")
1952 .expect("execute Ok — a materialize skip must not fail the spawn");
1953 }
1954
1955 #[tokio::test]
1966 async fn execute_with_project_root_only_appends_ctx_projection_pointer_default_placement() {
1967 use mlua_swarm::Operator;
1968 use tokio::sync::mpsc;
1969
1970 let dir = tempfile::TempDir::new().unwrap();
1971 let mut ctx = test_ctx("ST-proj-3");
1972 ctx.meta.runtime.insert(
1973 TASK_PROJECT_ROOT_KEY.to_string(),
1974 Value::String(dir.path().to_string_lossy().into_owned()),
1975 );
1976
1977 let (tx, mut rx) = mpsc::unbounded_channel();
1978 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1979 SessionId::parse("S-proj-3").unwrap(),
1980 tx,
1981 None,
1982 ));
1983
1984 let session_bg = session.clone();
1985 let handle = tokio::spawn(async move {
1986 session_bg
1987 .execute(
1988 &ctx,
1989 None,
1990 "".into(),
1991 Some(test_worker_binding()),
1992 test_cap_token(),
1993 )
1994 .await
1995 });
1996
1997 let sent = rx.recv().await.expect("Spawn sent");
1998 let req_id = match sent {
1999 ServerMsg::Spawn {
2000 req_id, directive, ..
2001 } => {
2002 assert!(
2003 directive.contains("ctx_projection:"),
2004 "work_dir absent must still fall back to project_root: {directive}"
2005 );
2006 req_id
2007 }
2008 other => panic!("expected Spawn, got {other:?}"),
2009 };
2010
2011 session
2012 .resolve_pending(
2013 &req_id,
2014 PendingReply::SpawnAck {
2015 value: serde_json::json!({}),
2016 ok: true,
2017 error: None,
2018 stats: None,
2019 },
2020 )
2021 .await;
2022 handle.await.expect("join").expect("execute Ok");
2023
2024 let expected_file = dir.path().join("workspace/tasks/ST-proj-3/ctx/_ctx.md");
2025 assert!(
2026 expected_file.exists(),
2027 "materialized projection file missing at {expected_file:?}"
2028 );
2029 }
2030
2031 #[tokio::test]
2039 async fn execute_with_custom_projection_placement_uses_declared_root_and_template() {
2040 use mlua_swarm::core::projection_placement::{ProjectionPlacement, RootPreference};
2041 use mlua_swarm::Operator;
2042 use tokio::sync::mpsc;
2043
2044 let work_dir = tempfile::TempDir::new().unwrap();
2045 let project_root = tempfile::TempDir::new().unwrap();
2046 let mut ctx = test_ctx("ST-proj-4");
2047 ctx.meta.runtime.insert(
2048 TASK_WORK_DIR_KEY.to_string(),
2049 Value::String(work_dir.path().to_string_lossy().into_owned()),
2050 );
2051 ctx.meta.runtime.insert(
2052 TASK_PROJECT_ROOT_KEY.to_string(),
2053 Value::String(project_root.path().to_string_lossy().into_owned()),
2054 );
2055 let placement = ProjectionPlacement {
2056 root_preference: RootPreference::ProjectRoot,
2057 dir_template: "custom/{task_id}/out".to_string(),
2058 };
2059 ctx.meta.runtime.insert(
2060 PROJECTION_PLACEMENT_KEY.to_string(),
2061 serde_json::to_value(&placement).expect("placement serializes"),
2062 );
2063
2064 let (tx, mut rx) = mpsc::unbounded_channel();
2065 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
2066 SessionId::parse("S-proj-4").unwrap(),
2067 tx,
2068 None,
2069 ));
2070
2071 let session_bg = session.clone();
2072 let handle = tokio::spawn(async move {
2073 session_bg
2074 .execute(
2075 &ctx,
2076 None,
2077 "".into(),
2078 Some(test_worker_binding()),
2079 test_cap_token(),
2080 )
2081 .await
2082 });
2083
2084 let sent = rx.recv().await.expect("Spawn sent");
2085 let req_id = match sent {
2086 ServerMsg::Spawn {
2087 req_id, directive, ..
2088 } => {
2089 assert!(
2090 directive.contains("ctx_projection:"),
2091 "directive missing ctx_projection pointer line: {directive}"
2092 );
2093 req_id
2094 }
2095 other => panic!("expected Spawn, got {other:?}"),
2096 };
2097
2098 session
2099 .resolve_pending(
2100 &req_id,
2101 PendingReply::SpawnAck {
2102 value: serde_json::json!({}),
2103 ok: true,
2104 error: None,
2105 stats: None,
2106 },
2107 )
2108 .await;
2109 handle.await.expect("join").expect("execute Ok");
2110
2111 let expected_file = project_root.path().join("custom/ST-proj-4/out/_ctx.md");
2112 assert!(
2113 expected_file.exists(),
2114 "materialized projection file missing at custom placement target {expected_file:?}"
2115 );
2116 let unexpected_file = work_dir
2117 .path()
2118 .join("workspace/tasks/ST-proj-4/ctx/_ctx.md");
2119 assert!(
2120 !unexpected_file.exists(),
2121 "declared root_preference=ProjectRoot must not fall back to work_dir: {unexpected_file:?}"
2122 );
2123 }
2124}