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(super) async fn resolve_pending(&self, req_id: &str, reply: PendingReply) {
101 if let Some(otx) = self.pending.lock().await.remove(req_id) {
102 let _ = otx.send(reply);
103 }
104 }
105
106 async fn send_and_await(&self, req_id: String, msg: ServerMsg) -> Result<PendingReply, String> {
110 let (otx, orx) = oneshot::channel::<PendingReply>();
111 self.pending.lock().await.insert(req_id.clone(), otx);
112
113 let send_result = {
115 let guard = self.tx.lock().await;
116 match guard.as_ref() {
117 Some(tx) => tx
118 .send(msg)
119 .map_err(|_| "ws send channel closed".to_string()),
120 None => Err("ws operator disconnected".to_string()),
121 }
122 };
123 if let Err(e) = send_result {
124 self.pending.lock().await.remove(&req_id);
125 return Err(e);
126 }
127
128 orx.await
129 .map_err(|_| "ws operator: oneshot cancelled (= reply path closed)".to_string())
130 }
131
132 async fn send_oneway(&self, msg: ServerMsg) -> Result<(), String> {
134 let guard = self.tx.lock().await;
135 match guard.as_ref() {
136 Some(tx) => tx
137 .send(msg)
138 .map_err(|_| "ws send channel closed".to_string()),
139 None => Err("ws operator disconnected".to_string()),
140 }
141 }
142}
143
144#[async_trait]
145impl SeniorBridge for WSOperatorSession {
146 async fn ask(&self, task_id: &StepId, question: Value) -> Result<Value, String> {
147 let req_id = format!("{}-ask-{}", self.sid, uuid::Uuid::new_v4());
148 let msg = ServerMsg::Ask {
149 req_id: req_id.clone(),
150 parent_req_id: current_parent_req_id(),
151 task_id: task_id.clone(),
152 question,
153 };
154 match self.send_and_await(req_id, msg).await? {
155 PendingReply::Answer(v) => Ok(v),
156 PendingReply::HookAck { .. } => {
157 Err("ws operator: unexpected hook_ack reply to ask".into())
158 }
159 PendingReply::SpawnAck { .. } => {
160 Err("ws operator: unexpected spawn_ack reply to ask".into())
161 }
162 PendingReply::SpawnHalt { .. } => {
163 Err("ws operator: unexpected spawn_halt reply to ask".into())
164 }
165 }
166 }
167}
168
169#[async_trait]
170impl SpawnHook for WSOperatorSession {
171 async fn before(&self, ctx: &Ctx) -> Result<(), String> {
172 let req_id = format!("{}-hb-{}", self.sid, uuid::Uuid::new_v4());
173 let msg = ServerMsg::HookBefore {
174 req_id: req_id.clone(),
175 parent_req_id: current_parent_req_id(),
176 task_id: ctx.task_id.clone(),
177 agent: ctx.agent.clone(),
178 attempt: ctx.attempt,
179 };
180 match self.send_and_await(req_id, msg).await? {
181 PendingReply::HookAck { ok: true, .. } => Ok(()),
182 PendingReply::HookAck { ok: false, reason } => {
183 Err(reason.unwrap_or_else(|| "ws operator: spawn rejected".into()))
184 }
185 PendingReply::Answer(_) => {
186 Err("ws operator: unexpected answer reply to hook_before".into())
187 }
188 PendingReply::SpawnAck { .. } => {
189 Err("ws operator: unexpected spawn_ack reply to hook_before".into())
190 }
191 PendingReply::SpawnHalt { .. } => {
192 Err("ws operator: unexpected spawn_halt reply to hook_before".into())
193 }
194 }
195 }
196
197 async fn after(&self, ctx: &Ctx, result: &Value) -> Result<(), String> {
198 let req_id = format!("{}-ha-{}", self.sid, uuid::Uuid::new_v4());
199 let msg = ServerMsg::HookAfter {
200 req_id,
201 parent_req_id: current_parent_req_id(),
202 task_id: ctx.task_id.clone(),
203 agent: ctx.agent.clone(),
204 attempt: ctx.attempt,
205 result: result.clone(),
206 };
207 let _ = self.send_oneway(msg).await;
209 Ok(())
210 }
211}
212
213#[async_trait]
214impl Operator for WSOperatorSession {
215 async fn execute(
238 &self,
239 ctx: &Ctx,
240 _system: Option<String>,
241 prompt: Value,
242 worker: Option<WorkerBinding>,
243 worker_token: CapToken,
244 ) -> Result<WorkerResult, WorkerError> {
245 let Some(worker) = worker else {
246 return Err(WorkerError::Failed(format!(
247 "agent '{}' has no worker_binding; WS thin-path requires one \
248 (Blueprint AgentDef.profile.worker_binding)",
249 ctx.agent
250 )));
251 };
252 let req_id = format!("{}-spawn-{}", self.sid, uuid::Uuid::new_v4());
253 let worker_handle = ctx
254 .meta
255 .runtime
256 .get("worker_handle")
257 .and_then(|v| v.as_str())
258 .map(|s| s.to_string());
259 let data_sink_endpoint = ctx
260 .meta
261 .runtime
262 .get("data_sink_endpoint")
263 .and_then(|v| v.as_str());
264 let run_id = ctx.meta.runtime.get("run_id").and_then(|v| v.as_str());
269 let view = AgentContextView::materialized_or_from_ctx(ctx);
278 let directive = default_spawn_directive_with_task_directive(
285 &ctx.agent,
286 ctx.task_id.as_str(),
287 &worker.variant,
288 &view,
289 data_sink_endpoint,
290 self.base_url.as_deref(),
291 run_id,
292 &prompt,
293 );
294 let projection_placement = ctx
302 .meta
303 .runtime
304 .get(PROJECTION_PLACEMENT_KEY)
305 .and_then(|v| serde_json::from_value::<ProjectionPlacement>(v.clone()).ok())
306 .unwrap_or_default();
307 let directive = append_projection_pointer(
312 directive,
313 &ctx.task_id,
314 &view,
315 run_id,
316 &projection_placement,
317 );
318 let msg = ServerMsg::Spawn {
319 req_id: req_id.clone(),
320 parent_req_id: current_parent_req_id(),
321 task_id: ctx.task_id.clone(),
322 agent: ctx.agent.clone(),
323 attempt: ctx.attempt,
324 capability_token: worker_token.encode(),
325 worker_handle,
326 worker: Some(worker),
327 directive,
328 };
329 match self.send_and_await(req_id, msg).await {
330 Ok(PendingReply::SpawnAck {
331 value,
332 ok,
333 error: None,
334 }) => Ok(WorkerResult { value, ok }),
335 Ok(PendingReply::SpawnAck {
336 error: Some(msg), ..
337 }) => Err(WorkerError::Failed(msg)),
338 Ok(PendingReply::SpawnHalt { value, reason }) => {
345 let marker = serde_json::json!({
346 "halted": true,
347 "reason": reason,
348 "value": value,
349 });
350 Ok(WorkerResult {
351 value: marker,
352 ok: true,
353 })
354 }
355 Ok(_) => Err(WorkerError::Failed(
356 "ws operator: unexpected non-spawn reply".into(),
357 )),
358 Err(e) => Err(WorkerError::Failed(format!("ws operator spawn: {e}"))),
359 }
360 }
361
362 fn requires_worker_binding(&self) -> bool {
363 true
364 }
365}
366
367#[allow(clippy::too_many_arguments)]
425pub(super) fn default_spawn_directive(
426 agent: &str,
427 task_id: &str,
428 subagent_type: &str,
429 view: &AgentContextView,
430 data_sink_endpoint: Option<&str>,
431 base_url: Option<&str>,
432 run_id: Option<&str>,
433) -> String {
434 let context_header = view.to_directive_header();
438 let data_endpoint_block = match data_sink_endpoint {
445 Some(base) => format!(
446 "\n\
447 [Data path endpoint — MainAgent reminder]\n\
448 When you kick a SubAgent, inject the following two lines into\n\
449 its prompt / environment so Big Response payloads (4k+ tokens,\n\
450 files, intermediate artifacts) flow directly to the Store owner,\n\
451 bypassing the MainAgent (context stays small; only the out_id\n\
452 ref is passed around).\n \
453 DATA_EMIT: {base}/v1/data/emit (POST, auth = Bearer worker_handle or ?token=)\n \
454 DATA_GET: {base}/v1/data/<out_id|out_name> (the next SubAgent fetches from $IN_REFS)\n\
455 When a SubAgent produces a Big Response, POST it to DATA_EMIT\n\
456 and return only the one-line out_id ref (do not mix the body\n\
457 in; the MainAgent must not answer directly).\n\
458 \n"
459 ),
460 None => String::new(),
461 };
462 let main_ai_reminder = match view.project_name_alias.as_deref() {
463 Some(a) => format!(
464 "\n\
465 [LDS Session Alias Reminder — MainAI mandatory]\n\
466 Before kicking the SubAgent below, call:\n \
467 mcp__lds__session_create(root=<working_dir>, alias=\"{a}\")\n\
468 (= establish a single task-level lds session; reuse on repeated dispatch).\n\
469 Then add this literal line to the SubAgent prompt body below:\n \
470 LDS Session Alias: {a}\n\
471 The SubAgent will call mcp__lds__session_start(alias=\"{a}\") on init,\n\
472 keeping worktree ownership unified across dispatches.\n\
473 (Full discipline rationale is inlined above; reach is via this directive itself,\n\
474 not via any external doc path. The 2 steps above are the complete contract.)\n\
475 \n"
476 ),
477 None => String::new(),
478 };
479 let base_url_line = match base_url {
483 Some(u) => u.to_string(),
484 None => "<your server's actual bind — check with mse_doctor>".to_string(),
485 };
486 let run_route_line = match run_id {
491 Some(rid) => format!("GET <base_url>/v1/runs/{rid}"),
492 None => "GET <base_url>/v1/runs/<run_id>".to_string(),
493 };
494 format!(
495 "[agent_primitive dispatch=@{agent}]\n\
496 worker endpoint:\n \
497 GET <base_url>/v1/worker/prompt?task_id={task_id}\n \
498 POST <base_url>/v1/worker/submit\n\
499 auth: Bearer <worker_handle from THIS Spawn payload (= short `wh-XXXXXXXX` form)>\n\
500 task_id: {task_id}\n\
501 agent_id: {agent}\n\
502 {context_header}\
503 {data_endpoint_block}\
504 {main_ai_reminder}\
505 Kick a SubAgent via Agent tool with subagent_type=\"{subagent_type}\" (= project-local \
506 `.claude/agents/{subagent_type}.md`, this agent's Blueprint-declared worker binding). \
507 The prompt you pass to it MUST be EXACTLY these 4 lines (no preamble, no extra text):\n\
508 \n \
509 agent_id: {agent}\n \
510 worker_handle: <THIS Spawn payload's `worker_handle` field (short string `wh-XXXXXXXX`)>\n \
511 base_url: {base_url_line}\n \
512 task_id: {task_id}\n\
513 \n\
514 The SubAgent self-fetches system + prompt via GET (Bearer = handle), \
515 executes as agent @{agent}, POSTs raw body to /v1/worker/submit (Bearer = handle, \
516 server resolves task_id from handle), and replies `OUTPUT` 1 word. You then forward \
517 SpawnAck {{req_id, value:{{}}, ok:true}} through your operator client — MCP path: \
518 mse_ack(sid, req_id, kind=\"spawn_ack\", ok=true) (= empty value because canonical \
519 body lives in output_tail via the POST). \
520 Do NOT fetch /v1/worker/prompt yourself. Do NOT wrap, summarize, or field-select \
521 the SubAgent reply. Observation / debug is a separate channel (= agent-inspect MCP / \
522 {run_route_line}), do NOT mix it into the forward path. \
523 If the SubAgent type is not registered, FAIL LOUD: reply SpawnAck ok=false with an \
524 error explaining the missing `.claude/agents/{subagent_type}.md` — do NOT fall back \
525 to another subagent_type."
526 )
527}
528
529#[allow(clippy::too_many_arguments)]
543pub(super) fn default_spawn_directive_with_task_directive(
544 agent: &str,
545 task_id: &str,
546 subagent_type: &str,
547 view: &AgentContextView,
548 data_sink_endpoint: Option<&str>,
549 base_url: Option<&str>,
550 run_id: Option<&str>,
551 task_directive: &Value,
552) -> String {
553 let base = default_spawn_directive(
554 agent,
555 task_id,
556 subagent_type,
557 view,
558 data_sink_endpoint,
559 base_url,
560 run_id,
561 );
562 let task_directive_line = match task_directive {
567 Value::Null => String::new(),
568 Value::String(s) => format!("task_directive: {s}\n"),
569 other => format!("task_directive: {other}\n"),
570 };
571 format!("{base}{task_directive_line}")
572}
573
574fn append_projection_pointer(
609 directive: String,
610 task_id: &StepId,
611 view: &AgentContextView,
612 run_id: Option<&str>,
613 placement: &ProjectionPlacement,
614) -> String {
615 let Some(root) = placement.resolve_root(view) else {
616 return directive;
617 };
618 match serde_json::to_value(view) {
619 Ok(ctx_data) => {
620 let key = ProjectionKey {
621 task_id: task_id.to_string(),
622 run_id: run_id.map(str::to_string),
623 step: None,
624 path: None,
625 };
626 let adapter = FileProjectionAdapter::with_placement(root, placement.clone());
627 match adapter.project(&key, &ctx_data) {
628 Ok(reference) => {
629 let pointer_value = match &reference {
630 ProjectionRef::File { path } => serde_json::json!({ "file": path }),
631 ProjectionRef::Query { endpoint, key } => {
632 serde_json::json!({ "endpoint": endpoint, "key": key })
633 }
634 };
635 format!("{directive}ctx_projection: {pointer_value}\n")
636 }
637 Err(err) => {
638 tracing::warn!(
639 %task_id,
640 error = %err,
641 "projection hook: materialize failed, spawning without a pointer"
642 );
643 directive
644 }
645 }
646 }
647 Err(err) => {
648 tracing::warn!(
649 %task_id,
650 error = %err,
651 "projection hook: AgentContextView serialize failed, spawning without a pointer"
652 );
653 directive
654 }
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use mlua_swarm::core::agent_context::{
662 TASK_METADATA_KEY, TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY,
663 };
664
665 fn view_with(
672 alias: Option<&str>,
673 project_root: Option<&str>,
674 work_dir: Option<&str>,
675 ) -> AgentContextView {
676 AgentContextView {
677 project_name_alias: alias.map(String::from),
678 project_root: project_root.map(String::from),
679 work_dir: work_dir.map(String::from),
680 ..AgentContextView::default()
681 }
682 }
683
684 #[tokio::test]
685 async fn connection_state_tracks_the_current_sender() {
686 let (tx, _rx) = mpsc::unbounded_channel();
687 let session = WSOperatorSession::new_with_base_url(
688 SessionId::parse("S-connection-state").unwrap(),
689 tx.clone(),
690 None,
691 );
692 assert!(session.is_connected().await);
693
694 session.clear_tx_if(&tx).await;
695 assert!(!session.is_connected().await);
696 }
697
698 #[tokio::test]
699 async fn stale_disconnect_does_not_clear_a_reconnected_sender() {
700 let (old_tx, _old_rx) = mpsc::unbounded_channel();
701 let (new_tx, _new_rx) = mpsc::unbounded_channel();
702 let session = WSOperatorSession::new_with_base_url(
703 SessionId::parse("S-reconnect-state").unwrap(),
704 old_tx.clone(),
705 None,
706 );
707
708 session.replace_tx(new_tx).await;
709 session.clear_tx_if(&old_tx).await;
710
711 assert!(session.is_connected().await);
712 }
713
714 #[test]
715 fn directive_omits_project_name_alias_when_none() {
716 let d = default_spawn_directive(
717 "impl-lead",
718 "task-x",
719 "mse-worker-coder",
720 &view_with(None, None, None),
721 None,
722 None,
723 None,
724 );
725 assert!(!d.contains("project_name_alias:"));
726 assert!(!d.contains("LDS Session Alias"));
727 assert!(!d.contains("session_create"));
728 }
729
730 #[test]
731 fn directive_emits_project_name_alias_when_some() {
732 let d = default_spawn_directive(
733 "impl-lead",
734 "task-x",
735 "mse-worker-coder",
736 &view_with(Some("mse-task-7785"), None, None),
737 None,
738 None,
739 None,
740 );
741 assert!(
743 d.contains("project_name_alias: mse-task-7785"),
744 "directive missing project_name_alias header: {d}"
745 );
746 assert!(
748 d.contains("mcp__lds__session_create(root=<working_dir>, alias=\"mse-task-7785\")"),
749 "directive missing session_create reminder: {d}"
750 );
751 assert!(
752 d.contains("LDS Session Alias: mse-task-7785"),
753 "directive missing SubAgent prompt inject line: {d}"
754 );
755 assert!(
757 d.contains("inlined above") || d.contains("complete contract"),
758 "directive should inline rationale rather than point at external doc: {d}"
759 );
760 let forbidden_doc_ref = format!(".{}/CLAUDE.md", "claude");
769 assert!(
770 !d.contains(&forbidden_doc_ref),
771 "directive must not reference {forbidden_doc_ref} (out of MainAI scope): {d}"
772 );
773 }
774
775 #[test]
776 fn directive_omits_data_endpoint_when_none() {
777 let d = default_spawn_directive(
778 "impl-lead",
779 "task-x",
780 "mse-worker-coder",
781 &view_with(None, None, None),
782 None,
783 None,
784 None,
785 );
786 assert!(!d.contains("[Data path endpoint"));
787 assert!(!d.contains("DATA_EMIT"));
788 assert!(!d.contains("DATA_GET"));
789 }
790
791 #[test]
792 fn directive_emits_data_endpoint_when_some() {
793 let base = "http://127.0.0.1:7785";
794 let d = default_spawn_directive(
795 "impl-lead",
796 "task-x",
797 "mse-worker-coder",
798 &view_with(None, None, None),
799 Some(base),
800 None,
801 None,
802 );
803 assert!(
804 d.contains("[Data path endpoint"),
805 "directive missing data endpoint block header: {d}"
806 );
807 assert!(
808 d.contains(&format!("DATA_EMIT: {base}/v1/data/emit")),
809 "directive missing single-mouth emit line: {d}"
810 );
811 assert!(
812 d.contains("Bearer worker_handle or ?token="),
813 "directive missing auth transport hint: {d}"
814 );
815 assert!(
816 d.contains(&format!("DATA_GET: {base}/v1/data/<out_id|out_name>")),
817 "directive missing GET line: {d}"
818 );
819 assert!(
820 !d.contains("emit-auth"),
821 "old split endpoint must not leak into directive: {d}"
822 );
823 assert!(
824 d.contains("bypassing the MainAgent") && d.contains("out_id ref"),
825 "directive should carry the ownership + bypass reasoning: {d}"
826 );
827 }
828
829 #[test]
830 fn directive_carries_declared_subagent_type_and_has_no_fallback() {
831 let d = default_spawn_directive(
832 "impl-lead",
833 "task-x",
834 "mse-worker-coder",
835 &view_with(None, None, None),
836 None,
837 None,
838 None,
839 );
840 assert!(
841 d.contains("subagent_type=\"mse-worker-coder\""),
842 "directive must carry the Blueprint-declared subagent_type literally: {d}"
843 );
844 assert!(
845 d.contains(".claude/agents/mse-worker-coder.md"),
846 "directive must reference the declared subagent's own .md path: {d}"
847 );
848 assert!(
850 !d.contains("general-purpose"),
851 "directive must not fall back to subagent_type=\"general-purpose\": {d}"
852 );
853 assert!(
854 !d.contains("mse-worker\""),
855 "directive must not carry the old hardcoded \"mse-worker\" literal: {d}"
856 );
857 assert!(
858 d.contains("FAIL LOUD"),
859 "directive must instruct the MainAI to fail loud instead of falling back: {d}"
860 );
861 }
862
863 #[test]
869 fn directive_renders_actual_base_url_when_some() {
870 let d = default_spawn_directive(
871 "impl-lead",
872 "task-x",
873 "mse-worker-coder",
874 &view_with(None, None, None),
875 None,
876 Some("http://127.0.0.1:8888"),
877 None,
878 );
879 assert!(
880 d.contains("base_url: http://127.0.0.1:8888"),
881 "directive must render the actual bind literally: {d}"
882 );
883 assert!(
884 !d.contains("mse_doctor"),
885 "no mse_doctor detour when bind is known: {d}"
886 );
887 }
888
889 #[test]
893 fn directive_falls_back_to_mse_doctor_pointer_when_none() {
894 let d = default_spawn_directive(
895 "impl-lead",
896 "task-x",
897 "mse-worker-coder",
898 &view_with(None, None, None),
899 None,
900 None,
901 None,
902 );
903 assert!(
904 d.contains("check with mse_doctor"),
905 "fallback must point at mse_doctor: {d}"
906 );
907 }
908
909 #[test]
913 fn directive_never_contains_stale_example_port_7786() {
914 for base in [
915 None,
916 Some("http://127.0.0.1:7777"),
917 Some("http://192.0.2.1:9000"),
918 ] {
919 let d = default_spawn_directive(
920 "impl-lead",
921 "task-x",
922 "mse-worker-coder",
923 &view_with(Some("mse-task-alias"), None, None),
924 Some("http://127.0.0.1:7785"),
925 base,
926 None,
927 );
928 assert!(
929 !d.contains("7786"),
930 "stale example port 7786 leaked: base={base:?}, d={d}"
931 );
932 }
933 }
934
935 #[test]
941 fn directive_never_contains_stale_tasks_id_route() {
942 let d = default_spawn_directive(
943 "impl-lead",
944 "task-x",
945 "mse-worker-coder",
946 &view_with(None, None, None),
947 None,
948 None,
949 Some("R-abc123"),
950 );
951 assert!(
952 !d.contains("/v1/tasks/{id}") && !d.contains("/v1/tasks/{{id}}"),
953 "stale /v1/tasks/{{id}} observation hint leaked: {d}"
954 );
955 }
956
957 #[test]
960 fn directive_renders_actual_run_id_when_some() {
961 let d = default_spawn_directive(
962 "impl-lead",
963 "task-x",
964 "mse-worker-coder",
965 &view_with(None, None, None),
966 None,
967 None,
968 Some("R-abc123"),
969 );
970 assert!(
971 d.contains("GET <base_url>/v1/runs/R-abc123"),
972 "directive missing real run_id in observation route: {d}"
973 );
974 }
975
976 #[test]
979 fn directive_falls_back_to_run_id_placeholder_when_none() {
980 let d = default_spawn_directive(
981 "impl-lead",
982 "task-x",
983 "mse-worker-coder",
984 &view_with(None, None, None),
985 None,
986 None,
987 None,
988 );
989 assert!(
990 d.contains("GET <base_url>/v1/runs/<run_id>"),
991 "directive missing placeholder observation route: {d}"
992 );
993 }
994
995 #[test]
1000 fn directive_omits_project_root_and_work_dir_when_both_none() {
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 None,
1009 );
1010 assert!(!d.contains("project_root:"));
1011 assert!(!d.contains("work_dir:"));
1012 }
1013
1014 #[test]
1017 fn directive_splices_project_root_and_work_dir_when_both_present() {
1018 let d = default_spawn_directive(
1019 "impl-lead",
1020 "task-x",
1021 "mse-worker-coder",
1022 &view_with(None, Some("/repo"), Some("/repo/work")),
1023 None,
1024 None,
1025 None,
1026 );
1027 assert!(
1028 d.contains("project_root: /repo"),
1029 "directive missing project_root header: {d}"
1030 );
1031 assert!(
1032 d.contains("work_dir: /repo/work"),
1033 "directive missing work_dir header: {d}"
1034 );
1035 }
1036
1037 #[test]
1040 fn directive_splices_project_root_only_when_work_dir_absent() {
1041 let d = default_spawn_directive(
1042 "impl-lead",
1043 "task-x",
1044 "mse-worker-coder",
1045 &view_with(None, Some("/repo"), None),
1046 None,
1047 None,
1048 None,
1049 );
1050 assert!(
1051 d.contains("project_root: /repo"),
1052 "directive missing project_root header: {d}"
1053 );
1054 assert!(!d.contains("work_dir:"));
1055 }
1056
1057 #[test]
1064 fn directive_splices_task_metadata_when_some() {
1065 let view = AgentContextView {
1066 task_metadata: Some(serde_json::json!({"issue": 20})),
1067 ..view_with(None, Some("/repo"), None)
1068 };
1069 let d = default_spawn_directive(
1070 "impl-lead",
1071 "task-x",
1072 "mse-worker-coder",
1073 &view,
1074 None,
1075 None,
1076 None,
1077 );
1078 assert!(
1079 d.contains(r#"task_metadata: {"issue":20}"#),
1080 "directive missing task_metadata header: {d}"
1081 );
1082 assert!(d.contains("project_root: /repo"));
1084 }
1085
1086 #[test]
1090 fn directive_omits_task_metadata_when_none() {
1091 let d = default_spawn_directive(
1092 "impl-lead",
1093 "task-x",
1094 "mse-worker-coder",
1095 &view_with(None, None, None),
1096 None,
1097 None,
1098 None,
1099 );
1100 assert!(!d.contains("task_metadata:"));
1101 }
1102
1103 fn test_ctx(task_id: &str) -> mlua_swarm::Ctx {
1106 mlua_swarm::Ctx::new(mlua_swarm::StepId::parse(task_id).unwrap(), 1, "a")
1107 }
1108
1109 fn test_worker_binding() -> mlua_swarm::WorkerBinding {
1110 mlua_swarm::WorkerBinding {
1111 variant: "test-variant".into(),
1112 tools: vec![],
1113 request_digest: None,
1114 requested_model: None,
1115 }
1116 }
1117
1118 fn test_cap_token() -> mlua_swarm::CapToken {
1119 mlua_swarm::CapToken {
1120 agent_id: "a".into(),
1121 role: mlua_swarm::Role::Worker,
1122 scopes: vec!["*".into()],
1123 issued_at: 0,
1124 expire_at: u64::MAX / 2,
1125 max_uses: None,
1126 nonce: "test-nonce".into(),
1127 sig_hex: "".into(),
1128 }
1129 }
1130
1131 #[tokio::test]
1137 async fn spawn_halt_reply_lands_as_ok_worker_result_with_marker() {
1138 use mlua_swarm::Operator;
1139 use tokio::sync::mpsc;
1140
1141 let (tx, mut rx) = mpsc::unbounded_channel();
1142 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1143 SessionId::parse("S-halt").unwrap(),
1144 tx,
1145 None,
1146 ));
1147
1148 let session_bg = session.clone();
1151 let handle = tokio::spawn(async move {
1152 session_bg
1153 .execute(
1154 &test_ctx("ST-halt"),
1155 None,
1156 "".into(),
1157 Some(test_worker_binding()),
1158 test_cap_token(),
1159 )
1160 .await
1161 });
1162
1163 let sent = rx.recv().await.expect("Spawn sent");
1164 let req_id = match sent {
1165 ServerMsg::Spawn { req_id, .. } => req_id,
1166 other => panic!("expected Spawn, got {other:?}"),
1167 };
1168
1169 session
1170 .resolve_pending(
1171 &req_id,
1172 PendingReply::SpawnHalt {
1173 value: serde_json::json!({"partial": "abc"}),
1174 reason: Some("shape verified".into()),
1175 },
1176 )
1177 .await;
1178
1179 let result = handle.await.expect("join").expect("execute Ok");
1180 assert!(
1181 result.ok,
1182 "spawn_halt must land as ok=true (normal termination), got: {result:?}"
1183 );
1184 assert_eq!(result.value["halted"], true);
1185 assert_eq!(result.value["reason"], "shape verified");
1186 assert_eq!(result.value["value"], serde_json::json!({"partial": "abc"}));
1187 }
1188
1189 #[tokio::test]
1192 async fn spawn_ack_with_error_still_lands_as_worker_error() {
1193 use mlua_swarm::{Operator, WorkerError};
1194 use tokio::sync::mpsc;
1195
1196 let (tx, mut rx) = mpsc::unbounded_channel();
1197 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1198 SessionId::parse("S-err").unwrap(),
1199 tx,
1200 None,
1201 ));
1202
1203 let session_bg = session.clone();
1204 let handle = tokio::spawn(async move {
1205 session_bg
1206 .execute(
1207 &test_ctx("ST-err"),
1208 None,
1209 "".into(),
1210 Some(test_worker_binding()),
1211 test_cap_token(),
1212 )
1213 .await
1214 });
1215
1216 let sent = rx.recv().await.expect("Spawn sent");
1217 let req_id = match sent {
1218 ServerMsg::Spawn { req_id, .. } => req_id,
1219 other => panic!("expected Spawn, got {other:?}"),
1220 };
1221
1222 session
1223 .resolve_pending(
1224 &req_id,
1225 PendingReply::SpawnAck {
1226 value: serde_json::json!({}),
1227 ok: false,
1228 error: Some("real crash".into()),
1229 },
1230 )
1231 .await;
1232
1233 let err = handle.await.expect("join").expect_err("must be error");
1234 assert!(matches!(err, WorkerError::Failed(msg) if msg.contains("real crash")));
1235 }
1236
1237 #[tokio::test]
1244 async fn execute_splices_project_root_and_work_dir_from_ctx_meta_runtime() {
1245 use mlua_swarm::Operator;
1246 use tokio::sync::mpsc;
1247
1248 let (tx, mut rx) = mpsc::unbounded_channel();
1249 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1250 SessionId::parse("S-ctxroot").unwrap(),
1251 tx,
1252 None,
1253 ));
1254
1255 let mut ctx = test_ctx("ST-ctxroot");
1256 ctx.meta.runtime.insert(
1257 TASK_PROJECT_ROOT_KEY.to_string(),
1258 serde_json::json!("/repo"),
1259 );
1260 ctx.meta.runtime.insert(
1261 TASK_WORK_DIR_KEY.to_string(),
1262 serde_json::json!("/repo/work"),
1263 );
1264
1265 let session_bg = session.clone();
1266 let handle = tokio::spawn(async move {
1267 session_bg
1268 .execute(
1269 &ctx,
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 {
1281 req_id, directive, ..
1282 } => {
1283 let directive = directive.as_str();
1287 assert!(
1288 directive.contains("project_root: /repo"),
1289 "directive missing project_root splice: {directive}"
1290 );
1291 assert!(
1292 directive.contains("work_dir: /repo/work"),
1293 "directive missing work_dir splice: {directive}"
1294 );
1295 req_id
1296 }
1297 other => panic!("expected Spawn, got {other:?}"),
1298 };
1299
1300 session
1301 .resolve_pending(
1302 &req_id,
1303 PendingReply::SpawnAck {
1304 value: serde_json::json!({}),
1305 ok: true,
1306 error: None,
1307 },
1308 )
1309 .await;
1310 handle.await.expect("join").expect("execute Ok");
1311 }
1312
1313 #[tokio::test]
1318 async fn execute_splices_project_root_only_when_ctx_meta_runtime_partial() {
1319 use mlua_swarm::Operator;
1320 use tokio::sync::mpsc;
1321
1322 let (tx, mut rx) = mpsc::unbounded_channel();
1323 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1324 SessionId::parse("S-ctxpartial").unwrap(),
1325 tx,
1326 None,
1327 ));
1328
1329 let mut ctx = test_ctx("ST-ctxpartial");
1330 ctx.meta.runtime.insert(
1331 TASK_PROJECT_ROOT_KEY.to_string(),
1332 serde_json::json!("/repo"),
1333 );
1334
1335 let session_bg = session.clone();
1336 let handle = tokio::spawn(async move {
1337 session_bg
1338 .execute(
1339 &ctx,
1340 None,
1341 "".into(),
1342 Some(test_worker_binding()),
1343 test_cap_token(),
1344 )
1345 .await
1346 });
1347
1348 let sent = rx.recv().await.expect("Spawn sent");
1349 let req_id = match sent {
1350 ServerMsg::Spawn {
1351 req_id, directive, ..
1352 } => {
1353 let directive = directive.as_str();
1354 assert!(
1355 directive.contains("project_root: /repo"),
1356 "directive missing project_root splice: {directive}"
1357 );
1358 assert!(!directive.contains("work_dir:"));
1359 req_id
1360 }
1361 other => panic!("expected Spawn, got {other:?}"),
1362 };
1363
1364 session
1365 .resolve_pending(
1366 &req_id,
1367 PendingReply::SpawnAck {
1368 value: serde_json::json!({}),
1369 ok: true,
1370 error: None,
1371 },
1372 )
1373 .await;
1374 handle.await.expect("join").expect("execute Ok");
1375 }
1376
1377 #[tokio::test]
1381 async fn execute_omits_project_root_and_work_dir_when_ctx_meta_runtime_absent() {
1382 use mlua_swarm::Operator;
1383 use tokio::sync::mpsc;
1384
1385 let (tx, mut rx) = mpsc::unbounded_channel();
1386 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1387 SessionId::parse("S-ctxabsent").unwrap(),
1388 tx,
1389 None,
1390 ));
1391
1392 let ctx = test_ctx("ST-ctxabsent");
1393
1394 let session_bg = session.clone();
1395 let handle = tokio::spawn(async move {
1396 session_bg
1397 .execute(
1398 &ctx,
1399 None,
1400 "".into(),
1401 Some(test_worker_binding()),
1402 test_cap_token(),
1403 )
1404 .await
1405 });
1406
1407 let sent = rx.recv().await.expect("Spawn sent");
1408 let req_id = match sent {
1409 ServerMsg::Spawn {
1410 req_id, directive, ..
1411 } => {
1412 let directive = directive.as_str();
1413 assert!(!directive.contains("project_root:"));
1414 assert!(!directive.contains("work_dir:"));
1415 req_id
1416 }
1417 other => panic!("expected Spawn, got {other:?}"),
1418 };
1419
1420 session
1421 .resolve_pending(
1422 &req_id,
1423 PendingReply::SpawnAck {
1424 value: serde_json::json!({}),
1425 ok: true,
1426 error: None,
1427 },
1428 )
1429 .await;
1430 handle.await.expect("join").expect("execute Ok");
1431 }
1432
1433 #[tokio::test]
1439 async fn execute_splices_task_metadata_from_ctx_meta_runtime() {
1440 use mlua_swarm::Operator;
1441 use tokio::sync::mpsc;
1442
1443 let (tx, mut rx) = mpsc::unbounded_channel();
1444 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1445 SessionId::parse("S-ctxmeta").unwrap(),
1446 tx,
1447 None,
1448 ));
1449
1450 let mut ctx = test_ctx("ST-ctxmeta");
1451 ctx.meta.runtime.insert(
1452 TASK_METADATA_KEY.to_string(),
1453 serde_json::json!({"issue": 20}),
1454 );
1455
1456 let session_bg = session.clone();
1457 let handle = tokio::spawn(async move {
1458 session_bg
1459 .execute(
1460 &ctx,
1461 None,
1462 "".into(),
1463 Some(test_worker_binding()),
1464 test_cap_token(),
1465 )
1466 .await
1467 });
1468
1469 let sent = rx.recv().await.expect("Spawn sent");
1470 let req_id = match sent {
1471 ServerMsg::Spawn {
1472 req_id, directive, ..
1473 } => {
1474 let directive = directive.as_str();
1475 assert!(
1476 directive.contains(r#"task_metadata: {"issue":20}"#),
1477 "directive missing task_metadata splice: {directive}"
1478 );
1479 req_id
1480 }
1481 other => panic!("expected Spawn, got {other:?}"),
1482 };
1483
1484 session
1485 .resolve_pending(
1486 &req_id,
1487 PendingReply::SpawnAck {
1488 value: serde_json::json!({}),
1489 ok: true,
1490 error: None,
1491 },
1492 )
1493 .await;
1494 handle.await.expect("join").expect("execute Ok");
1495 }
1496
1497 #[test]
1503 fn with_task_directive_splices_string_seed_verbatim() {
1504 let directive = default_spawn_directive_with_task_directive(
1505 "impl-lead",
1506 "task-x",
1507 "mse-worker-coder",
1508 &view_with(None, None, None),
1509 None,
1510 None,
1511 None,
1512 &serde_json::json!("do the thing"),
1513 );
1514 let text = directive.as_str();
1515 assert!(
1516 text.contains("task_directive: do the thing"),
1517 "missing task_directive line for a String seed: {text}"
1518 );
1519 }
1520
1521 #[test]
1525 fn with_task_directive_renders_object_seed_as_json_literal() {
1526 let directive = default_spawn_directive_with_task_directive(
1527 "impl-lead",
1528 "task-x",
1529 "mse-worker-coder",
1530 &view_with(None, None, None),
1531 None,
1532 None,
1533 None,
1534 &serde_json::json!({"key": "value"}),
1535 );
1536 let text = directive.as_str();
1537 assert!(
1538 text.contains(r#"task_directive: {"key":"value"}"#),
1539 "missing JSON-literal task_directive line for an Object seed: {text}"
1540 );
1541 }
1542
1543 #[test]
1547 fn with_task_directive_omits_line_when_null() {
1548 let wrapped = default_spawn_directive_with_task_directive(
1549 "impl-lead",
1550 "task-x",
1551 "mse-worker-coder",
1552 &view_with(None, None, None),
1553 None,
1554 None,
1555 None,
1556 &serde_json::Value::Null,
1557 );
1558 let plain = default_spawn_directive(
1559 "impl-lead",
1560 "task-x",
1561 "mse-worker-coder",
1562 &view_with(None, None, None),
1563 None,
1564 None,
1565 None,
1566 );
1567 assert_eq!(
1568 wrapped,
1569 serde_json::Value::String(plain),
1570 "Value::Null seed must not add a task_directive line"
1571 );
1572 }
1573
1574 #[tokio::test]
1581 async fn execute_splices_json_literal_task_directive_for_object_seed() {
1582 use mlua_swarm::Operator;
1583 use tokio::sync::mpsc;
1584
1585 let (tx, mut rx) = mpsc::unbounded_channel();
1586 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1587 SessionId::parse("S-objseed").unwrap(),
1588 tx,
1589 None,
1590 ));
1591
1592 let ctx = test_ctx("ST-objseed");
1593 let rendered_prompt = serde_json::json!({"key": "value"});
1598
1599 let session_bg = session.clone();
1600 let handle = tokio::spawn(async move {
1601 session_bg
1602 .execute(
1603 &ctx,
1604 None,
1605 rendered_prompt,
1606 Some(test_worker_binding()),
1607 test_cap_token(),
1608 )
1609 .await
1610 });
1611
1612 let sent = rx.recv().await.expect("Spawn sent");
1613 let req_id = match sent {
1614 ServerMsg::Spawn {
1615 req_id, directive, ..
1616 } => {
1617 let directive = directive.as_str();
1618 assert!(
1619 directive.contains(r#"task_directive: {"key":"value"}"#),
1620 "directive missing JSON-literal task_directive splice: {directive}"
1621 );
1622 req_id
1623 }
1624 other => panic!("expected Spawn, got {other:?}"),
1625 };
1626
1627 session
1628 .resolve_pending(
1629 &req_id,
1630 PendingReply::SpawnAck {
1631 value: serde_json::json!({}),
1632 ok: true,
1633 error: None,
1634 },
1635 )
1636 .await;
1637 handle.await.expect("join").expect("execute Ok");
1638 }
1639
1640 #[tokio::test]
1646 async fn execute_with_work_dir_appends_ctx_projection_pointer_and_materializes_file() {
1647 use mlua_swarm::Operator;
1648 use tokio::sync::mpsc;
1649
1650 let dir = tempfile::TempDir::new().unwrap();
1651 let mut ctx = test_ctx("ST-proj-1");
1652 ctx.meta.runtime.insert(
1653 TASK_WORK_DIR_KEY.to_string(),
1654 Value::String(dir.path().to_string_lossy().into_owned()),
1655 );
1656
1657 let (tx, mut rx) = mpsc::unbounded_channel();
1658 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1659 SessionId::parse("S-proj-1").unwrap(),
1660 tx,
1661 None,
1662 ));
1663
1664 let session_bg = session.clone();
1665 let handle = tokio::spawn(async move {
1666 session_bg
1667 .execute(
1668 &ctx,
1669 None,
1670 "".into(),
1671 Some(test_worker_binding()),
1672 test_cap_token(),
1673 )
1674 .await
1675 });
1676
1677 let sent = rx.recv().await.expect("Spawn sent");
1678 let req_id = match sent {
1679 ServerMsg::Spawn {
1680 req_id, directive, ..
1681 } => {
1682 assert!(
1683 directive.contains("ctx_projection:"),
1684 "directive missing ctx_projection pointer line: {directive}"
1685 );
1686 assert!(
1693 !directive.contains("ctx_step_dir:"),
1694 "directive must not carry the retired ctx_step_dir line: {directive}"
1695 );
1696 req_id
1697 }
1698 other => panic!("expected Spawn, got {other:?}"),
1699 };
1700
1701 session
1702 .resolve_pending(
1703 &req_id,
1704 PendingReply::SpawnAck {
1705 value: serde_json::json!({}),
1706 ok: true,
1707 error: None,
1708 },
1709 )
1710 .await;
1711 handle.await.expect("join").expect("execute Ok");
1712
1713 let expected_file = dir.path().join("workspace/tasks/ST-proj-1/ctx/_ctx.md");
1714 assert!(
1715 expected_file.exists(),
1716 "materialized projection file missing at {expected_file:?}"
1717 );
1718 }
1719
1720 #[tokio::test]
1725 async fn execute_without_work_dir_spawns_without_ctx_projection_pointer() {
1726 use mlua_swarm::Operator;
1727 use tokio::sync::mpsc;
1728
1729 let (tx, mut rx) = mpsc::unbounded_channel();
1730 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1731 SessionId::parse("S-proj-2").unwrap(),
1732 tx,
1733 None,
1734 ));
1735
1736 let session_bg = session.clone();
1737 let handle = tokio::spawn(async move {
1738 session_bg
1739 .execute(
1740 &test_ctx("ST-proj-2"),
1741 None,
1742 "".into(),
1743 Some(test_worker_binding()),
1744 test_cap_token(),
1745 )
1746 .await
1747 });
1748
1749 let sent = rx.recv().await.expect("Spawn sent");
1750 let req_id = match sent {
1751 ServerMsg::Spawn {
1752 req_id, directive, ..
1753 } => {
1754 assert!(
1755 !directive.contains("ctx_projection:"),
1756 "directive must not carry a pointer line when work_dir is absent \
1757 (fallback): {directive}"
1758 );
1759 req_id
1760 }
1761 other => panic!("expected Spawn, got {other:?}"),
1762 };
1763
1764 session
1765 .resolve_pending(
1766 &req_id,
1767 PendingReply::SpawnAck {
1768 value: serde_json::json!({}),
1769 ok: true,
1770 error: None,
1771 },
1772 )
1773 .await;
1774 handle
1775 .await
1776 .expect("join")
1777 .expect("execute Ok — a materialize skip must not fail the spawn");
1778 }
1779
1780 #[tokio::test]
1791 async fn execute_with_project_root_only_appends_ctx_projection_pointer_default_placement() {
1792 use mlua_swarm::Operator;
1793 use tokio::sync::mpsc;
1794
1795 let dir = tempfile::TempDir::new().unwrap();
1796 let mut ctx = test_ctx("ST-proj-3");
1797 ctx.meta.runtime.insert(
1798 TASK_PROJECT_ROOT_KEY.to_string(),
1799 Value::String(dir.path().to_string_lossy().into_owned()),
1800 );
1801
1802 let (tx, mut rx) = mpsc::unbounded_channel();
1803 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1804 SessionId::parse("S-proj-3").unwrap(),
1805 tx,
1806 None,
1807 ));
1808
1809 let session_bg = session.clone();
1810 let handle = tokio::spawn(async move {
1811 session_bg
1812 .execute(
1813 &ctx,
1814 None,
1815 "".into(),
1816 Some(test_worker_binding()),
1817 test_cap_token(),
1818 )
1819 .await
1820 });
1821
1822 let sent = rx.recv().await.expect("Spawn sent");
1823 let req_id = match sent {
1824 ServerMsg::Spawn {
1825 req_id, directive, ..
1826 } => {
1827 assert!(
1828 directive.contains("ctx_projection:"),
1829 "work_dir absent must still fall back to project_root: {directive}"
1830 );
1831 req_id
1832 }
1833 other => panic!("expected Spawn, got {other:?}"),
1834 };
1835
1836 session
1837 .resolve_pending(
1838 &req_id,
1839 PendingReply::SpawnAck {
1840 value: serde_json::json!({}),
1841 ok: true,
1842 error: None,
1843 },
1844 )
1845 .await;
1846 handle.await.expect("join").expect("execute Ok");
1847
1848 let expected_file = dir.path().join("workspace/tasks/ST-proj-3/ctx/_ctx.md");
1849 assert!(
1850 expected_file.exists(),
1851 "materialized projection file missing at {expected_file:?}"
1852 );
1853 }
1854
1855 #[tokio::test]
1863 async fn execute_with_custom_projection_placement_uses_declared_root_and_template() {
1864 use mlua_swarm::core::projection_placement::{ProjectionPlacement, RootPreference};
1865 use mlua_swarm::Operator;
1866 use tokio::sync::mpsc;
1867
1868 let work_dir = tempfile::TempDir::new().unwrap();
1869 let project_root = tempfile::TempDir::new().unwrap();
1870 let mut ctx = test_ctx("ST-proj-4");
1871 ctx.meta.runtime.insert(
1872 TASK_WORK_DIR_KEY.to_string(),
1873 Value::String(work_dir.path().to_string_lossy().into_owned()),
1874 );
1875 ctx.meta.runtime.insert(
1876 TASK_PROJECT_ROOT_KEY.to_string(),
1877 Value::String(project_root.path().to_string_lossy().into_owned()),
1878 );
1879 let placement = ProjectionPlacement {
1880 root_preference: RootPreference::ProjectRoot,
1881 dir_template: "custom/{task_id}/out".to_string(),
1882 };
1883 ctx.meta.runtime.insert(
1884 PROJECTION_PLACEMENT_KEY.to_string(),
1885 serde_json::to_value(&placement).expect("placement serializes"),
1886 );
1887
1888 let (tx, mut rx) = mpsc::unbounded_channel();
1889 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1890 SessionId::parse("S-proj-4").unwrap(),
1891 tx,
1892 None,
1893 ));
1894
1895 let session_bg = session.clone();
1896 let handle = tokio::spawn(async move {
1897 session_bg
1898 .execute(
1899 &ctx,
1900 None,
1901 "".into(),
1902 Some(test_worker_binding()),
1903 test_cap_token(),
1904 )
1905 .await
1906 });
1907
1908 let sent = rx.recv().await.expect("Spawn sent");
1909 let req_id = match sent {
1910 ServerMsg::Spawn {
1911 req_id, directive, ..
1912 } => {
1913 assert!(
1914 directive.contains("ctx_projection:"),
1915 "directive missing ctx_projection pointer line: {directive}"
1916 );
1917 req_id
1918 }
1919 other => panic!("expected Spawn, got {other:?}"),
1920 };
1921
1922 session
1923 .resolve_pending(
1924 &req_id,
1925 PendingReply::SpawnAck {
1926 value: serde_json::json!({}),
1927 ok: true,
1928 error: None,
1929 },
1930 )
1931 .await;
1932 handle.await.expect("join").expect("execute Ok");
1933
1934 let expected_file = project_root.path().join("custom/ST-proj-4/out/_ctx.md");
1935 assert!(
1936 expected_file.exists(),
1937 "materialized projection file missing at custom placement target {expected_file:?}"
1938 );
1939 let unexpected_file = work_dir
1940 .path()
1941 .join("workspace/tasks/ST-proj-4/ctx/_ctx.md");
1942 assert!(
1943 !unexpected_file.exists(),
1944 "declared root_preference=ProjectRoot must not fall back to work_dir: {unexpected_file:?}"
1945 );
1946 }
1947}