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 "implementer",
777 "task-x",
778 "code-worker",
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 "implementer",
793 "task-x",
794 "code-worker",
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");
831 assert!(
832 !d.contains(&forbidden_doc_ref),
833 "directive must not reference {forbidden_doc_ref} (out of MainAI scope): {d}"
834 );
835 }
836
837 #[test]
838 fn directive_omits_data_endpoint_when_none() {
839 let d = default_spawn_directive(
840 "implementer",
841 "task-x",
842 "code-worker",
843 &view_with(None, None, None),
844 None,
845 None,
846 None,
847 );
848 assert!(!d.contains("[Data path endpoint"));
849 assert!(!d.contains("DATA_EMIT"));
850 assert!(!d.contains("DATA_GET"));
851 }
852
853 #[test]
854 fn directive_emits_data_endpoint_when_some() {
855 let base = "http://127.0.0.1:7785";
856 let d = default_spawn_directive(
857 "implementer",
858 "task-x",
859 "code-worker",
860 &view_with(None, None, None),
861 Some(base),
862 None,
863 None,
864 );
865 assert!(
866 d.contains("[Data path endpoint"),
867 "directive missing data endpoint block header: {d}"
868 );
869 assert!(
870 d.contains(&format!("DATA_EMIT: {base}/v1/data/emit")),
871 "directive missing single-mouth emit line: {d}"
872 );
873 assert!(
874 d.contains("Bearer worker_handle or ?token="),
875 "directive missing auth transport hint: {d}"
876 );
877 assert!(
878 d.contains(&format!("DATA_GET: {base}/v1/data/<out_id|out_name>")),
879 "directive missing GET line: {d}"
880 );
881 assert!(
882 !d.contains("emit-auth"),
883 "old split endpoint must not leak into directive: {d}"
884 );
885 assert!(
886 d.contains("bypassing the MainAgent") && d.contains("out_id ref"),
887 "directive should carry the ownership + bypass reasoning: {d}"
888 );
889 }
890
891 #[test]
892 fn directive_carries_declared_subagent_type_and_has_no_fallback() {
893 let d = default_spawn_directive(
894 "implementer",
895 "task-x",
896 "code-worker",
897 &view_with(None, None, None),
898 None,
899 None,
900 None,
901 );
902 assert!(
903 d.contains("subagent_type=\"code-worker\""),
904 "directive must carry the Blueprint-declared subagent_type literally: {d}"
905 );
906 assert!(
907 d.contains(".claude/agents/code-worker.md"),
908 "directive must reference the declared subagent's own .md path: {d}"
909 );
910 assert!(
912 !d.contains("general-purpose"),
913 "directive must not fall back to subagent_type=\"general-purpose\": {d}"
914 );
915 assert!(
916 !d.contains("mse-worker\""),
917 "directive must not carry the old hardcoded \"mse-worker\" literal: {d}"
918 );
919 assert!(
920 d.contains("FAIL LOUD"),
921 "directive must instruct the MainAI to fail loud instead of falling back: {d}"
922 );
923 }
924
925 #[test]
931 fn directive_renders_actual_base_url_when_some() {
932 let d = default_spawn_directive(
933 "implementer",
934 "task-x",
935 "code-worker",
936 &view_with(None, None, None),
937 None,
938 Some("http://127.0.0.1:8888"),
939 None,
940 );
941 assert!(
942 d.contains("base_url: http://127.0.0.1:8888"),
943 "directive must render the actual bind literally: {d}"
944 );
945 assert!(
946 !d.contains("mse_doctor"),
947 "no mse_doctor detour when bind is known: {d}"
948 );
949 }
950
951 #[test]
955 fn directive_falls_back_to_mse_doctor_pointer_when_none() {
956 let d = default_spawn_directive(
957 "implementer",
958 "task-x",
959 "code-worker",
960 &view_with(None, None, None),
961 None,
962 None,
963 None,
964 );
965 assert!(
966 d.contains("check with mse_doctor"),
967 "fallback must point at mse_doctor: {d}"
968 );
969 }
970
971 #[test]
975 fn directive_never_contains_stale_example_port_7786() {
976 for base in [
977 None,
978 Some("http://127.0.0.1:7777"),
979 Some("http://192.0.2.1:9000"),
980 ] {
981 let d = default_spawn_directive(
982 "implementer",
983 "task-x",
984 "code-worker",
985 &view_with(Some("mse-task-alias"), None, None),
986 Some("http://127.0.0.1:7785"),
987 base,
988 None,
989 );
990 assert!(
991 !d.contains("7786"),
992 "stale example port 7786 leaked: base={base:?}, d={d}"
993 );
994 }
995 }
996
997 #[test]
1003 fn directive_never_contains_stale_tasks_id_route() {
1004 let d = default_spawn_directive(
1005 "implementer",
1006 "task-x",
1007 "code-worker",
1008 &view_with(None, None, None),
1009 None,
1010 None,
1011 Some("R-abc123"),
1012 );
1013 assert!(
1014 !d.contains("/v1/tasks/{id}") && !d.contains("/v1/tasks/{{id}}"),
1015 "stale /v1/tasks/{{id}} observation hint leaked: {d}"
1016 );
1017 }
1018
1019 #[test]
1022 fn directive_renders_actual_run_id_when_some() {
1023 let d = default_spawn_directive(
1024 "implementer",
1025 "task-x",
1026 "code-worker",
1027 &view_with(None, None, None),
1028 None,
1029 None,
1030 Some("R-abc123"),
1031 );
1032 assert!(
1033 d.contains("GET <base_url>/v1/runs/R-abc123"),
1034 "directive missing real run_id in observation route: {d}"
1035 );
1036 }
1037
1038 #[test]
1041 fn directive_falls_back_to_run_id_placeholder_when_none() {
1042 let d = default_spawn_directive(
1043 "implementer",
1044 "task-x",
1045 "code-worker",
1046 &view_with(None, None, None),
1047 None,
1048 None,
1049 None,
1050 );
1051 assert!(
1052 d.contains("GET <base_url>/v1/runs/<run_id>"),
1053 "directive missing placeholder observation route: {d}"
1054 );
1055 }
1056
1057 #[test]
1062 fn directive_omits_project_root_and_work_dir_when_both_none() {
1063 let d = default_spawn_directive(
1064 "implementer",
1065 "task-x",
1066 "code-worker",
1067 &view_with(None, None, None),
1068 None,
1069 None,
1070 None,
1071 );
1072 assert!(!d.contains("project_root:"));
1073 assert!(!d.contains("work_dir:"));
1074 }
1075
1076 #[test]
1079 fn directive_splices_project_root_and_work_dir_when_both_present() {
1080 let d = default_spawn_directive(
1081 "implementer",
1082 "task-x",
1083 "code-worker",
1084 &view_with(None, Some("/repo"), Some("/repo/work")),
1085 None,
1086 None,
1087 None,
1088 );
1089 assert!(
1090 d.contains("project_root: /repo"),
1091 "directive missing project_root header: {d}"
1092 );
1093 assert!(
1094 d.contains("work_dir: /repo/work"),
1095 "directive missing work_dir header: {d}"
1096 );
1097 }
1098
1099 #[test]
1102 fn directive_splices_project_root_only_when_work_dir_absent() {
1103 let d = default_spawn_directive(
1104 "implementer",
1105 "task-x",
1106 "code-worker",
1107 &view_with(None, Some("/repo"), None),
1108 None,
1109 None,
1110 None,
1111 );
1112 assert!(
1113 d.contains("project_root: /repo"),
1114 "directive missing project_root header: {d}"
1115 );
1116 assert!(!d.contains("work_dir:"));
1117 }
1118
1119 #[test]
1126 fn directive_splices_task_metadata_when_some() {
1127 let view = AgentContextView {
1128 task_metadata: Some(serde_json::json!({"issue": 20})),
1129 ..view_with(None, Some("/repo"), None)
1130 };
1131 let d = default_spawn_directive(
1132 "implementer",
1133 "task-x",
1134 "code-worker",
1135 &view,
1136 None,
1137 None,
1138 None,
1139 );
1140 assert!(
1141 d.contains(r#"task_metadata: {"issue":20}"#),
1142 "directive missing task_metadata header: {d}"
1143 );
1144 assert!(d.contains("project_root: /repo"));
1146 }
1147
1148 #[test]
1152 fn directive_omits_task_metadata_when_none() {
1153 let d = default_spawn_directive(
1154 "implementer",
1155 "task-x",
1156 "code-worker",
1157 &view_with(None, None, None),
1158 None,
1159 None,
1160 None,
1161 );
1162 assert!(!d.contains("task_metadata:"));
1163 }
1164
1165 fn test_ctx(task_id: &str) -> mlua_swarm::Ctx {
1168 mlua_swarm::Ctx::new(mlua_swarm::StepId::parse(task_id).unwrap(), 1, "a")
1169 }
1170
1171 fn test_worker_binding() -> mlua_swarm::WorkerBinding {
1172 mlua_swarm::WorkerBinding {
1173 variant: "test-variant".into(),
1174 tools: vec![],
1175 request_digest: None,
1176 requested_model: None,
1177 }
1178 }
1179
1180 fn test_cap_token() -> mlua_swarm::CapToken {
1181 mlua_swarm::CapToken {
1182 agent_id: "a".into(),
1183 role: mlua_swarm::Role::Worker,
1184 scopes: vec!["*".into()],
1185 issued_at: 0,
1186 expire_at: u64::MAX / 2,
1187 max_uses: None,
1188 nonce: "test-nonce".into(),
1189 sig_hex: "".into(),
1190 }
1191 }
1192
1193 #[tokio::test]
1199 async fn spawn_halt_reply_lands_as_ok_worker_result_with_marker() {
1200 use mlua_swarm::Operator;
1201 use tokio::sync::mpsc;
1202
1203 let (tx, mut rx) = mpsc::unbounded_channel();
1204 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1205 SessionId::parse("S-halt").unwrap(),
1206 tx,
1207 None,
1208 ));
1209
1210 let session_bg = session.clone();
1213 let handle = tokio::spawn(async move {
1214 session_bg
1215 .execute(
1216 &test_ctx("ST-halt"),
1217 None,
1218 "".into(),
1219 Some(test_worker_binding()),
1220 test_cap_token(),
1221 )
1222 .await
1223 });
1224
1225 let sent = rx.recv().await.expect("Spawn sent");
1226 let req_id = match sent {
1227 ServerMsg::Spawn { req_id, .. } => req_id,
1228 other => panic!("expected Spawn, got {other:?}"),
1229 };
1230
1231 session
1232 .resolve_pending(
1233 &req_id,
1234 PendingReply::SpawnHalt {
1235 value: serde_json::json!({"partial": "abc"}),
1236 reason: Some("shape verified".into()),
1237 },
1238 )
1239 .await;
1240
1241 let result = handle.await.expect("join").expect("execute Ok");
1242 assert!(
1243 result.ok,
1244 "spawn_halt must land as ok=true (normal termination), got: {result:?}"
1245 );
1246 assert_eq!(result.value["halted"], true);
1247 assert_eq!(result.value["reason"], "shape verified");
1248 assert_eq!(result.value["value"], serde_json::json!({"partial": "abc"}));
1249 }
1250
1251 #[tokio::test]
1254 async fn spawn_ack_with_error_still_lands_as_worker_error() {
1255 use mlua_swarm::{Operator, WorkerError};
1256 use tokio::sync::mpsc;
1257
1258 let (tx, mut rx) = mpsc::unbounded_channel();
1259 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1260 SessionId::parse("S-err").unwrap(),
1261 tx,
1262 None,
1263 ));
1264
1265 let session_bg = session.clone();
1266 let handle = tokio::spawn(async move {
1267 session_bg
1268 .execute(
1269 &test_ctx("ST-err"),
1270 None,
1271 "".into(),
1272 Some(test_worker_binding()),
1273 test_cap_token(),
1274 )
1275 .await
1276 });
1277
1278 let sent = rx.recv().await.expect("Spawn sent");
1279 let req_id = match sent {
1280 ServerMsg::Spawn { req_id, .. } => req_id,
1281 other => panic!("expected Spawn, got {other:?}"),
1282 };
1283
1284 session
1285 .resolve_pending(
1286 &req_id,
1287 PendingReply::SpawnAck {
1288 value: serde_json::json!({}),
1289 ok: false,
1290 error: Some("real crash".into()),
1291 stats: None,
1292 },
1293 )
1294 .await;
1295
1296 let err = handle.await.expect("join").expect_err("must be error");
1297 assert!(matches!(err, WorkerError::Failed(msg) if msg.contains("real crash")));
1298 }
1299
1300 #[tokio::test]
1306 async fn fail_pending_unblocks_a_parked_spawn_with_worker_error() {
1307 use mlua_swarm::{Operator, WorkerError};
1308 use tokio::sync::mpsc;
1309
1310 let (tx, mut rx) = mpsc::unbounded_channel();
1311 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1312 SessionId::parse("S-teardown").unwrap(),
1313 tx,
1314 None,
1315 ));
1316
1317 let session_bg = session.clone();
1318 let handle = tokio::spawn(async move {
1319 session_bg
1320 .execute(
1321 &test_ctx("ST-teardown"),
1322 None,
1323 "".into(),
1324 Some(test_worker_binding()),
1325 test_cap_token(),
1326 )
1327 .await
1328 });
1329
1330 let _sent = rx.recv().await.expect("Spawn sent");
1334
1335 session.fail_pending("operator session torn down").await;
1336
1337 let err = handle
1338 .await
1339 .expect("join")
1340 .expect_err("a parked spawn must fail once pending is drained");
1341 assert!(
1342 matches!(err, WorkerError::Failed(_)),
1343 "fail_pending must surface a WorkerError::Failed, got: {err:?}"
1344 );
1345 }
1346
1347 #[tokio::test]
1354 async fn execute_splices_project_root_and_work_dir_from_ctx_meta_runtime() {
1355 use mlua_swarm::Operator;
1356 use tokio::sync::mpsc;
1357
1358 let (tx, mut rx) = mpsc::unbounded_channel();
1359 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1360 SessionId::parse("S-ctxroot").unwrap(),
1361 tx,
1362 None,
1363 ));
1364
1365 let mut ctx = test_ctx("ST-ctxroot");
1366 ctx.meta.runtime.insert(
1367 TASK_PROJECT_ROOT_KEY.to_string(),
1368 serde_json::json!("/repo"),
1369 );
1370 ctx.meta.runtime.insert(
1371 TASK_WORK_DIR_KEY.to_string(),
1372 serde_json::json!("/repo/work"),
1373 );
1374
1375 let session_bg = session.clone();
1376 let handle = tokio::spawn(async move {
1377 session_bg
1378 .execute(
1379 &ctx,
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");
1389 let req_id = match sent {
1390 ServerMsg::Spawn {
1391 req_id, directive, ..
1392 } => {
1393 let directive = directive.as_str();
1397 assert!(
1398 directive.contains("project_root: /repo"),
1399 "directive missing project_root splice: {directive}"
1400 );
1401 assert!(
1402 directive.contains("work_dir: /repo/work"),
1403 "directive missing work_dir splice: {directive}"
1404 );
1405 req_id
1406 }
1407 other => panic!("expected Spawn, got {other:?}"),
1408 };
1409
1410 session
1411 .resolve_pending(
1412 &req_id,
1413 PendingReply::SpawnAck {
1414 value: serde_json::json!({}),
1415 ok: true,
1416 error: None,
1417 stats: None,
1418 },
1419 )
1420 .await;
1421 handle.await.expect("join").expect("execute Ok");
1422 }
1423
1424 #[tokio::test]
1429 async fn execute_splices_project_root_only_when_ctx_meta_runtime_partial() {
1430 use mlua_swarm::Operator;
1431 use tokio::sync::mpsc;
1432
1433 let (tx, mut rx) = mpsc::unbounded_channel();
1434 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1435 SessionId::parse("S-ctxpartial").unwrap(),
1436 tx,
1437 None,
1438 ));
1439
1440 let mut ctx = test_ctx("ST-ctxpartial");
1441 ctx.meta.runtime.insert(
1442 TASK_PROJECT_ROOT_KEY.to_string(),
1443 serde_json::json!("/repo"),
1444 );
1445
1446 let session_bg = session.clone();
1447 let handle = tokio::spawn(async move {
1448 session_bg
1449 .execute(
1450 &ctx,
1451 None,
1452 "".into(),
1453 Some(test_worker_binding()),
1454 test_cap_token(),
1455 )
1456 .await
1457 });
1458
1459 let sent = rx.recv().await.expect("Spawn sent");
1460 let req_id = match sent {
1461 ServerMsg::Spawn {
1462 req_id, directive, ..
1463 } => {
1464 let directive = directive.as_str();
1465 assert!(
1466 directive.contains("project_root: /repo"),
1467 "directive missing project_root splice: {directive}"
1468 );
1469 assert!(!directive.contains("work_dir:"));
1470 req_id
1471 }
1472 other => panic!("expected Spawn, got {other:?}"),
1473 };
1474
1475 session
1476 .resolve_pending(
1477 &req_id,
1478 PendingReply::SpawnAck {
1479 value: serde_json::json!({}),
1480 ok: true,
1481 error: None,
1482 stats: None,
1483 },
1484 )
1485 .await;
1486 handle.await.expect("join").expect("execute Ok");
1487 }
1488
1489 #[tokio::test]
1493 async fn execute_omits_project_root_and_work_dir_when_ctx_meta_runtime_absent() {
1494 use mlua_swarm::Operator;
1495 use tokio::sync::mpsc;
1496
1497 let (tx, mut rx) = mpsc::unbounded_channel();
1498 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1499 SessionId::parse("S-ctxabsent").unwrap(),
1500 tx,
1501 None,
1502 ));
1503
1504 let ctx = test_ctx("ST-ctxabsent");
1505
1506 let session_bg = session.clone();
1507 let handle = tokio::spawn(async move {
1508 session_bg
1509 .execute(
1510 &ctx,
1511 None,
1512 "".into(),
1513 Some(test_worker_binding()),
1514 test_cap_token(),
1515 )
1516 .await
1517 });
1518
1519 let sent = rx.recv().await.expect("Spawn sent");
1520 let req_id = match sent {
1521 ServerMsg::Spawn {
1522 req_id, directive, ..
1523 } => {
1524 let directive = directive.as_str();
1525 assert!(!directive.contains("project_root:"));
1526 assert!(!directive.contains("work_dir:"));
1527 req_id
1528 }
1529 other => panic!("expected Spawn, got {other:?}"),
1530 };
1531
1532 session
1533 .resolve_pending(
1534 &req_id,
1535 PendingReply::SpawnAck {
1536 value: serde_json::json!({}),
1537 ok: true,
1538 error: None,
1539 stats: None,
1540 },
1541 )
1542 .await;
1543 handle.await.expect("join").expect("execute Ok");
1544 }
1545
1546 #[tokio::test]
1552 async fn execute_splices_task_metadata_from_ctx_meta_runtime() {
1553 use mlua_swarm::Operator;
1554 use tokio::sync::mpsc;
1555
1556 let (tx, mut rx) = mpsc::unbounded_channel();
1557 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1558 SessionId::parse("S-ctxmeta").unwrap(),
1559 tx,
1560 None,
1561 ));
1562
1563 let mut ctx = test_ctx("ST-ctxmeta");
1564 ctx.meta.runtime.insert(
1565 TASK_METADATA_KEY.to_string(),
1566 serde_json::json!({"issue": 20}),
1567 );
1568
1569 let session_bg = session.clone();
1570 let handle = tokio::spawn(async move {
1571 session_bg
1572 .execute(
1573 &ctx,
1574 None,
1575 "".into(),
1576 Some(test_worker_binding()),
1577 test_cap_token(),
1578 )
1579 .await
1580 });
1581
1582 let sent = rx.recv().await.expect("Spawn sent");
1583 let req_id = match sent {
1584 ServerMsg::Spawn {
1585 req_id, directive, ..
1586 } => {
1587 let directive = directive.as_str();
1588 assert!(
1589 directive.contains(r#"task_metadata: {"issue":20}"#),
1590 "directive missing task_metadata splice: {directive}"
1591 );
1592 req_id
1593 }
1594 other => panic!("expected Spawn, got {other:?}"),
1595 };
1596
1597 session
1598 .resolve_pending(
1599 &req_id,
1600 PendingReply::SpawnAck {
1601 value: serde_json::json!({}),
1602 ok: true,
1603 error: None,
1604 stats: None,
1605 },
1606 )
1607 .await;
1608 handle.await.expect("join").expect("execute Ok");
1609 }
1610
1611 #[test]
1617 fn with_task_directive_splices_string_seed_verbatim() {
1618 let directive = default_spawn_directive_with_task_directive(
1619 "implementer",
1620 "task-x",
1621 "code-worker",
1622 &view_with(None, None, None),
1623 None,
1624 None,
1625 None,
1626 &serde_json::json!("do the thing"),
1627 );
1628 let text = directive.as_str();
1629 assert!(
1630 text.contains("task_directive: do the thing"),
1631 "missing task_directive line for a String seed: {text}"
1632 );
1633 }
1634
1635 #[test]
1639 fn with_task_directive_renders_object_seed_as_json_literal() {
1640 let directive = default_spawn_directive_with_task_directive(
1641 "implementer",
1642 "task-x",
1643 "code-worker",
1644 &view_with(None, None, None),
1645 None,
1646 None,
1647 None,
1648 &serde_json::json!({"key": "value"}),
1649 );
1650 let text = directive.as_str();
1651 assert!(
1652 text.contains(r#"task_directive: {"key":"value"}"#),
1653 "missing JSON-literal task_directive line for an Object seed: {text}"
1654 );
1655 }
1656
1657 #[test]
1661 fn with_task_directive_omits_line_when_null() {
1662 let wrapped = default_spawn_directive_with_task_directive(
1663 "implementer",
1664 "task-x",
1665 "code-worker",
1666 &view_with(None, None, None),
1667 None,
1668 None,
1669 None,
1670 &serde_json::Value::Null,
1671 );
1672 let plain = default_spawn_directive(
1673 "implementer",
1674 "task-x",
1675 "code-worker",
1676 &view_with(None, None, None),
1677 None,
1678 None,
1679 None,
1680 );
1681 assert_eq!(
1682 wrapped,
1683 serde_json::Value::String(plain),
1684 "Value::Null seed must not add a task_directive line"
1685 );
1686 }
1687
1688 #[tokio::test]
1695 async fn execute_splices_json_literal_task_directive_for_object_seed() {
1696 use mlua_swarm::Operator;
1697 use tokio::sync::mpsc;
1698
1699 let (tx, mut rx) = mpsc::unbounded_channel();
1700 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1701 SessionId::parse("S-objseed").unwrap(),
1702 tx,
1703 None,
1704 ));
1705
1706 let ctx = test_ctx("ST-objseed");
1707 let rendered_prompt = serde_json::json!({"key": "value"});
1712
1713 let session_bg = session.clone();
1714 let handle = tokio::spawn(async move {
1715 session_bg
1716 .execute(
1717 &ctx,
1718 None,
1719 rendered_prompt,
1720 Some(test_worker_binding()),
1721 test_cap_token(),
1722 )
1723 .await
1724 });
1725
1726 let sent = rx.recv().await.expect("Spawn sent");
1727 let req_id = match sent {
1728 ServerMsg::Spawn {
1729 req_id, directive, ..
1730 } => {
1731 let directive = directive.as_str();
1732 assert!(
1733 directive.contains(r#"task_directive: {"key":"value"}"#),
1734 "directive missing JSON-literal task_directive splice: {directive}"
1735 );
1736 req_id
1737 }
1738 other => panic!("expected Spawn, got {other:?}"),
1739 };
1740
1741 session
1742 .resolve_pending(
1743 &req_id,
1744 PendingReply::SpawnAck {
1745 value: serde_json::json!({}),
1746 ok: true,
1747 error: None,
1748 stats: None,
1749 },
1750 )
1751 .await;
1752 handle.await.expect("join").expect("execute Ok");
1753 }
1754
1755 #[tokio::test]
1761 async fn execute_with_work_dir_appends_ctx_projection_pointer_and_materializes_file() {
1762 use mlua_swarm::Operator;
1763 use tokio::sync::mpsc;
1764
1765 let dir = tempfile::TempDir::new().unwrap();
1766 let mut ctx = test_ctx("ST-proj-1");
1767 ctx.meta.runtime.insert(
1768 TASK_WORK_DIR_KEY.to_string(),
1769 Value::String(dir.path().to_string_lossy().into_owned()),
1770 );
1771
1772 let (tx, mut rx) = mpsc::unbounded_channel();
1773 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1774 SessionId::parse("S-proj-1").unwrap(),
1775 tx,
1776 None,
1777 ));
1778
1779 let session_bg = session.clone();
1780 let handle = tokio::spawn(async move {
1781 session_bg
1782 .execute(
1783 &ctx,
1784 None,
1785 "".into(),
1786 Some(test_worker_binding()),
1787 test_cap_token(),
1788 )
1789 .await
1790 });
1791
1792 let sent = rx.recv().await.expect("Spawn sent");
1793 let req_id = match sent {
1794 ServerMsg::Spawn {
1795 req_id, directive, ..
1796 } => {
1797 assert!(
1798 directive.contains("ctx_projection:"),
1799 "directive missing ctx_projection pointer line: {directive}"
1800 );
1801 assert!(
1808 !directive.contains("ctx_step_dir:"),
1809 "directive must not carry the retired ctx_step_dir line: {directive}"
1810 );
1811 req_id
1812 }
1813 other => panic!("expected Spawn, got {other:?}"),
1814 };
1815
1816 session
1817 .resolve_pending(
1818 &req_id,
1819 PendingReply::SpawnAck {
1820 value: serde_json::json!({}),
1821 ok: true,
1822 error: None,
1823 stats: None,
1824 },
1825 )
1826 .await;
1827 handle.await.expect("join").expect("execute Ok");
1828
1829 let expected_file = dir.path().join("workspace/tasks/ST-proj-1/ctx/_ctx.md");
1830 assert!(
1831 expected_file.exists(),
1832 "materialized projection file missing at {expected_file:?}"
1833 );
1834 }
1835
1836 #[tokio::test]
1841 async fn execute_without_work_dir_spawns_without_ctx_projection_pointer() {
1842 use mlua_swarm::Operator;
1843 use tokio::sync::mpsc;
1844
1845 let (tx, mut rx) = mpsc::unbounded_channel();
1846 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1847 SessionId::parse("S-proj-2").unwrap(),
1848 tx,
1849 None,
1850 ));
1851
1852 let session_bg = session.clone();
1853 let handle = tokio::spawn(async move {
1854 session_bg
1855 .execute(
1856 &test_ctx("ST-proj-2"),
1857 None,
1858 "".into(),
1859 Some(test_worker_binding()),
1860 test_cap_token(),
1861 )
1862 .await
1863 });
1864
1865 let sent = rx.recv().await.expect("Spawn sent");
1866 let req_id = match sent {
1867 ServerMsg::Spawn {
1868 req_id, directive, ..
1869 } => {
1870 assert!(
1871 !directive.contains("ctx_projection:"),
1872 "directive must not carry a pointer line when work_dir is absent \
1873 (fallback): {directive}"
1874 );
1875 req_id
1876 }
1877 other => panic!("expected Spawn, got {other:?}"),
1878 };
1879
1880 session
1881 .resolve_pending(
1882 &req_id,
1883 PendingReply::SpawnAck {
1884 value: serde_json::json!({}),
1885 ok: true,
1886 error: None,
1887 stats: None,
1888 },
1889 )
1890 .await;
1891 handle
1892 .await
1893 .expect("join")
1894 .expect("execute Ok — a materialize skip must not fail the spawn");
1895 }
1896
1897 #[tokio::test]
1908 async fn execute_with_project_root_only_appends_ctx_projection_pointer_default_placement() {
1909 use mlua_swarm::Operator;
1910 use tokio::sync::mpsc;
1911
1912 let dir = tempfile::TempDir::new().unwrap();
1913 let mut ctx = test_ctx("ST-proj-3");
1914 ctx.meta.runtime.insert(
1915 TASK_PROJECT_ROOT_KEY.to_string(),
1916 Value::String(dir.path().to_string_lossy().into_owned()),
1917 );
1918
1919 let (tx, mut rx) = mpsc::unbounded_channel();
1920 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1921 SessionId::parse("S-proj-3").unwrap(),
1922 tx,
1923 None,
1924 ));
1925
1926 let session_bg = session.clone();
1927 let handle = tokio::spawn(async move {
1928 session_bg
1929 .execute(
1930 &ctx,
1931 None,
1932 "".into(),
1933 Some(test_worker_binding()),
1934 test_cap_token(),
1935 )
1936 .await
1937 });
1938
1939 let sent = rx.recv().await.expect("Spawn sent");
1940 let req_id = match sent {
1941 ServerMsg::Spawn {
1942 req_id, directive, ..
1943 } => {
1944 assert!(
1945 directive.contains("ctx_projection:"),
1946 "work_dir absent must still fall back to project_root: {directive}"
1947 );
1948 req_id
1949 }
1950 other => panic!("expected Spawn, got {other:?}"),
1951 };
1952
1953 session
1954 .resolve_pending(
1955 &req_id,
1956 PendingReply::SpawnAck {
1957 value: serde_json::json!({}),
1958 ok: true,
1959 error: None,
1960 stats: None,
1961 },
1962 )
1963 .await;
1964 handle.await.expect("join").expect("execute Ok");
1965
1966 let expected_file = dir.path().join("workspace/tasks/ST-proj-3/ctx/_ctx.md");
1967 assert!(
1968 expected_file.exists(),
1969 "materialized projection file missing at {expected_file:?}"
1970 );
1971 }
1972
1973 #[tokio::test]
1981 async fn execute_with_custom_projection_placement_uses_declared_root_and_template() {
1982 use mlua_swarm::core::projection_placement::{ProjectionPlacement, RootPreference};
1983 use mlua_swarm::Operator;
1984 use tokio::sync::mpsc;
1985
1986 let work_dir = tempfile::TempDir::new().unwrap();
1987 let project_root = tempfile::TempDir::new().unwrap();
1988 let mut ctx = test_ctx("ST-proj-4");
1989 ctx.meta.runtime.insert(
1990 TASK_WORK_DIR_KEY.to_string(),
1991 Value::String(work_dir.path().to_string_lossy().into_owned()),
1992 );
1993 ctx.meta.runtime.insert(
1994 TASK_PROJECT_ROOT_KEY.to_string(),
1995 Value::String(project_root.path().to_string_lossy().into_owned()),
1996 );
1997 let placement = ProjectionPlacement {
1998 root_preference: RootPreference::ProjectRoot,
1999 dir_template: "custom/{task_id}/out".to_string(),
2000 };
2001 ctx.meta.runtime.insert(
2002 PROJECTION_PLACEMENT_KEY.to_string(),
2003 serde_json::to_value(&placement).expect("placement serializes"),
2004 );
2005
2006 let (tx, mut rx) = mpsc::unbounded_channel();
2007 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
2008 SessionId::parse("S-proj-4").unwrap(),
2009 tx,
2010 None,
2011 ));
2012
2013 let session_bg = session.clone();
2014 let handle = tokio::spawn(async move {
2015 session_bg
2016 .execute(
2017 &ctx,
2018 None,
2019 "".into(),
2020 Some(test_worker_binding()),
2021 test_cap_token(),
2022 )
2023 .await
2024 });
2025
2026 let sent = rx.recv().await.expect("Spawn sent");
2027 let req_id = match sent {
2028 ServerMsg::Spawn {
2029 req_id, directive, ..
2030 } => {
2031 assert!(
2032 directive.contains("ctx_projection:"),
2033 "directive missing ctx_projection pointer line: {directive}"
2034 );
2035 req_id
2036 }
2037 other => panic!("expected Spawn, got {other:?}"),
2038 };
2039
2040 session
2041 .resolve_pending(
2042 &req_id,
2043 PendingReply::SpawnAck {
2044 value: serde_json::json!({}),
2045 ok: true,
2046 error: None,
2047 stats: None,
2048 },
2049 )
2050 .await;
2051 handle.await.expect("join").expect("execute Ok");
2052
2053 let expected_file = project_root.path().join("custom/ST-proj-4/out/_ctx.md");
2054 assert!(
2055 expected_file.exists(),
2056 "materialized projection file missing at custom placement target {expected_file:?}"
2057 );
2058 let unexpected_file = work_dir
2059 .path()
2060 .join("workspace/tasks/ST-proj-4/ctx/_ctx.md");
2061 assert!(
2062 !unexpected_file.exists(),
2063 "declared root_preference=ProjectRoot must not fall back to work_dir: {unexpected_file:?}"
2064 );
2065 }
2066}