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(crate) async fn clear_tx(&self) {
76 *self.tx.lock().await = None;
77 }
78
79 pub(super) async fn resolve_pending(&self, req_id: &str, reply: PendingReply) {
82 if let Some(otx) = self.pending.lock().await.remove(req_id) {
83 let _ = otx.send(reply);
84 }
85 }
86
87 async fn send_and_await(&self, req_id: String, msg: ServerMsg) -> Result<PendingReply, String> {
91 let (otx, orx) = oneshot::channel::<PendingReply>();
92 self.pending.lock().await.insert(req_id.clone(), otx);
93
94 let send_result = {
96 let guard = self.tx.lock().await;
97 match guard.as_ref() {
98 Some(tx) => tx
99 .send(msg)
100 .map_err(|_| "ws send channel closed".to_string()),
101 None => Err("ws operator disconnected".to_string()),
102 }
103 };
104 if let Err(e) = send_result {
105 self.pending.lock().await.remove(&req_id);
106 return Err(e);
107 }
108
109 orx.await
110 .map_err(|_| "ws operator: oneshot cancelled (= reply path closed)".to_string())
111 }
112
113 async fn send_oneway(&self, msg: ServerMsg) -> Result<(), String> {
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}
124
125#[async_trait]
126impl SeniorBridge for WSOperatorSession {
127 async fn ask(&self, task_id: &StepId, question: Value) -> Result<Value, String> {
128 let req_id = format!("{}-ask-{}", self.sid, uuid::Uuid::new_v4());
129 let msg = ServerMsg::Ask {
130 req_id: req_id.clone(),
131 parent_req_id: current_parent_req_id(),
132 task_id: task_id.clone(),
133 question,
134 };
135 match self.send_and_await(req_id, msg).await? {
136 PendingReply::Answer(v) => Ok(v),
137 PendingReply::HookAck { .. } => {
138 Err("ws operator: unexpected hook_ack reply to ask".into())
139 }
140 PendingReply::SpawnAck { .. } => {
141 Err("ws operator: unexpected spawn_ack reply to ask".into())
142 }
143 PendingReply::SpawnHalt { .. } => {
144 Err("ws operator: unexpected spawn_halt reply to ask".into())
145 }
146 }
147 }
148}
149
150#[async_trait]
151impl SpawnHook for WSOperatorSession {
152 async fn before(&self, ctx: &Ctx) -> Result<(), String> {
153 let req_id = format!("{}-hb-{}", self.sid, uuid::Uuid::new_v4());
154 let msg = ServerMsg::HookBefore {
155 req_id: req_id.clone(),
156 parent_req_id: current_parent_req_id(),
157 task_id: ctx.task_id.clone(),
158 agent: ctx.agent.clone(),
159 attempt: ctx.attempt,
160 };
161 match self.send_and_await(req_id, msg).await? {
162 PendingReply::HookAck { ok: true, .. } => Ok(()),
163 PendingReply::HookAck { ok: false, reason } => {
164 Err(reason.unwrap_or_else(|| "ws operator: spawn rejected".into()))
165 }
166 PendingReply::Answer(_) => {
167 Err("ws operator: unexpected answer reply to hook_before".into())
168 }
169 PendingReply::SpawnAck { .. } => {
170 Err("ws operator: unexpected spawn_ack reply to hook_before".into())
171 }
172 PendingReply::SpawnHalt { .. } => {
173 Err("ws operator: unexpected spawn_halt reply to hook_before".into())
174 }
175 }
176 }
177
178 async fn after(&self, ctx: &Ctx, result: &Value) -> Result<(), String> {
179 let req_id = format!("{}-ha-{}", self.sid, uuid::Uuid::new_v4());
180 let msg = ServerMsg::HookAfter {
181 req_id,
182 parent_req_id: current_parent_req_id(),
183 task_id: ctx.task_id.clone(),
184 agent: ctx.agent.clone(),
185 attempt: ctx.attempt,
186 result: result.clone(),
187 };
188 let _ = self.send_oneway(msg).await;
190 Ok(())
191 }
192}
193
194#[async_trait]
195impl Operator for WSOperatorSession {
196 async fn execute(
219 &self,
220 ctx: &Ctx,
221 _system: Option<String>,
222 prompt: Value,
223 worker: Option<WorkerBinding>,
224 worker_token: CapToken,
225 ) -> Result<WorkerResult, WorkerError> {
226 let Some(worker) = worker else {
227 return Err(WorkerError::Failed(format!(
228 "agent '{}' has no worker_binding; WS thin-path requires one \
229 (Blueprint AgentDef.profile.worker_binding)",
230 ctx.agent
231 )));
232 };
233 let req_id = format!("{}-spawn-{}", self.sid, uuid::Uuid::new_v4());
234 let worker_handle = ctx
235 .meta
236 .runtime
237 .get("worker_handle")
238 .and_then(|v| v.as_str())
239 .map(|s| s.to_string());
240 let data_sink_endpoint = ctx
241 .meta
242 .runtime
243 .get("data_sink_endpoint")
244 .and_then(|v| v.as_str());
245 let run_id = ctx.meta.runtime.get("run_id").and_then(|v| v.as_str());
250 let view = AgentContextView::materialized_or_from_ctx(ctx);
259 let directive = default_spawn_directive_with_task_directive(
266 &ctx.agent,
267 ctx.task_id.as_str(),
268 &worker.variant,
269 &view,
270 data_sink_endpoint,
271 self.base_url.as_deref(),
272 run_id,
273 &prompt,
274 );
275 let projection_placement = ctx
283 .meta
284 .runtime
285 .get(PROJECTION_PLACEMENT_KEY)
286 .and_then(|v| serde_json::from_value::<ProjectionPlacement>(v.clone()).ok())
287 .unwrap_or_default();
288 let directive = append_projection_pointer(
293 directive,
294 &ctx.task_id,
295 &view,
296 run_id,
297 &projection_placement,
298 );
299 let msg = ServerMsg::Spawn {
300 req_id: req_id.clone(),
301 parent_req_id: current_parent_req_id(),
302 task_id: ctx.task_id.clone(),
303 agent: ctx.agent.clone(),
304 attempt: ctx.attempt,
305 capability_token: worker_token.encode(),
306 worker_handle,
307 worker: Some(worker),
308 directive,
309 };
310 match self.send_and_await(req_id, msg).await {
311 Ok(PendingReply::SpawnAck {
312 value,
313 ok,
314 error: None,
315 }) => Ok(WorkerResult { value, ok }),
316 Ok(PendingReply::SpawnAck {
317 error: Some(msg), ..
318 }) => Err(WorkerError::Failed(msg)),
319 Ok(PendingReply::SpawnHalt { value, reason }) => {
326 let marker = serde_json::json!({
327 "halted": true,
328 "reason": reason,
329 "value": value,
330 });
331 Ok(WorkerResult {
332 value: marker,
333 ok: true,
334 })
335 }
336 Ok(_) => Err(WorkerError::Failed(
337 "ws operator: unexpected non-spawn reply".into(),
338 )),
339 Err(e) => Err(WorkerError::Failed(format!("ws operator spawn: {e}"))),
340 }
341 }
342
343 fn requires_worker_binding(&self) -> bool {
344 true
345 }
346}
347
348#[allow(clippy::too_many_arguments)]
406pub(super) fn default_spawn_directive(
407 agent: &str,
408 task_id: &str,
409 subagent_type: &str,
410 view: &AgentContextView,
411 data_sink_endpoint: Option<&str>,
412 base_url: Option<&str>,
413 run_id: Option<&str>,
414) -> String {
415 let context_header = view.to_directive_header();
419 let data_endpoint_block = match data_sink_endpoint {
426 Some(base) => format!(
427 "\n\
428 [Data path endpoint — MainAgent reminder]\n\
429 When you kick a SubAgent, inject the following two lines into\n\
430 its prompt / environment so Big Response payloads (4k+ tokens,\n\
431 files, intermediate artifacts) flow directly to the Store owner,\n\
432 bypassing the MainAgent (context stays small; only the out_id\n\
433 ref is passed around).\n \
434 DATA_EMIT: {base}/v1/data/emit (POST, auth = Bearer worker_handle or ?token=)\n \
435 DATA_GET: {base}/v1/data/<out_id|out_name> (the next SubAgent fetches from $IN_REFS)\n\
436 When a SubAgent produces a Big Response, POST it to DATA_EMIT\n\
437 and return only the one-line out_id ref (do not mix the body\n\
438 in; the MainAgent must not answer directly).\n\
439 \n"
440 ),
441 None => String::new(),
442 };
443 let main_ai_reminder = match view.project_name_alias.as_deref() {
444 Some(a) => format!(
445 "\n\
446 [LDS Session Alias Reminder — MainAI mandatory]\n\
447 Before kicking the SubAgent below, call:\n \
448 mcp__lds__session_create(root=<working_dir>, alias=\"{a}\")\n\
449 (= establish a single task-level lds session; reuse on repeated dispatch).\n\
450 Then add this literal line to the SubAgent prompt body below:\n \
451 LDS Session Alias: {a}\n\
452 The SubAgent will call mcp__lds__session_start(alias=\"{a}\") on init,\n\
453 keeping worktree ownership unified across dispatches.\n\
454 (Full discipline rationale is inlined above; reach is via this directive itself,\n\
455 not via any external doc path. The 2 steps above are the complete contract.)\n\
456 \n"
457 ),
458 None => String::new(),
459 };
460 let base_url_line = match base_url {
464 Some(u) => u.to_string(),
465 None => "<your server's actual bind — check with mse_doctor>".to_string(),
466 };
467 let run_route_line = match run_id {
472 Some(rid) => format!("GET <base_url>/v1/runs/{rid}"),
473 None => "GET <base_url>/v1/runs/<run_id>".to_string(),
474 };
475 format!(
476 "[agent_primitive dispatch=@{agent}]\n\
477 worker endpoint:\n \
478 GET <base_url>/v1/worker/prompt?task_id={task_id}\n \
479 POST <base_url>/v1/worker/submit\n\
480 auth: Bearer <worker_handle from THIS Spawn payload (= short `wh-XXXXXXXX` form)>\n\
481 task_id: {task_id}\n\
482 agent_id: {agent}\n\
483 {context_header}\
484 {data_endpoint_block}\
485 {main_ai_reminder}\
486 Kick a SubAgent via Agent tool with subagent_type=\"{subagent_type}\" (= project-local \
487 `.claude/agents/{subagent_type}.md`, this agent's Blueprint-declared worker binding). \
488 The prompt you pass to it MUST be EXACTLY these 4 lines (no preamble, no extra text):\n\
489 \n \
490 agent_id: {agent}\n \
491 worker_handle: <THIS Spawn payload's `worker_handle` field (short string `wh-XXXXXXXX`)>\n \
492 base_url: {base_url_line}\n \
493 task_id: {task_id}\n\
494 \n\
495 The SubAgent self-fetches system + prompt via GET (Bearer = handle), \
496 executes as agent @{agent}, POSTs raw body to /v1/worker/submit (Bearer = handle, \
497 server resolves task_id from handle), and replies `OUTPUT` 1 word. You then forward \
498 SpawnAck {{req_id, value:{{}}, ok:true}} through your operator client — MCP path: \
499 mse_ack(sid, req_id, kind=\"spawn_ack\", ok=true) (= empty value because canonical \
500 body lives in output_tail via the POST). \
501 Do NOT fetch /v1/worker/prompt yourself. Do NOT wrap, summarize, or field-select \
502 the SubAgent reply. Observation / debug is a separate channel (= agent-inspect MCP / \
503 {run_route_line}), do NOT mix it into the forward path. \
504 If the SubAgent type is not registered, FAIL LOUD: reply SpawnAck ok=false with an \
505 error explaining the missing `.claude/agents/{subagent_type}.md` — do NOT fall back \
506 to another subagent_type."
507 )
508}
509
510#[allow(clippy::too_many_arguments)]
524pub(super) fn default_spawn_directive_with_task_directive(
525 agent: &str,
526 task_id: &str,
527 subagent_type: &str,
528 view: &AgentContextView,
529 data_sink_endpoint: Option<&str>,
530 base_url: Option<&str>,
531 run_id: Option<&str>,
532 task_directive: &Value,
533) -> String {
534 let base = default_spawn_directive(
535 agent,
536 task_id,
537 subagent_type,
538 view,
539 data_sink_endpoint,
540 base_url,
541 run_id,
542 );
543 let task_directive_line = match task_directive {
548 Value::Null => String::new(),
549 Value::String(s) => format!("task_directive: {s}\n"),
550 other => format!("task_directive: {other}\n"),
551 };
552 format!("{base}{task_directive_line}")
553}
554
555fn append_projection_pointer(
590 directive: String,
591 task_id: &StepId,
592 view: &AgentContextView,
593 run_id: Option<&str>,
594 placement: &ProjectionPlacement,
595) -> String {
596 let Some(root) = placement.resolve_root(view) else {
597 return directive;
598 };
599 match serde_json::to_value(view) {
600 Ok(ctx_data) => {
601 let key = ProjectionKey {
602 task_id: task_id.to_string(),
603 run_id: run_id.map(str::to_string),
604 step: None,
605 path: None,
606 };
607 let adapter = FileProjectionAdapter::with_placement(root, placement.clone());
608 match adapter.project(&key, &ctx_data) {
609 Ok(reference) => {
610 let pointer_value = match &reference {
611 ProjectionRef::File { path } => serde_json::json!({ "file": path }),
612 ProjectionRef::Query { endpoint, key } => {
613 serde_json::json!({ "endpoint": endpoint, "key": key })
614 }
615 };
616 format!("{directive}ctx_projection: {pointer_value}\n")
617 }
618 Err(err) => {
619 tracing::warn!(
620 %task_id,
621 error = %err,
622 "projection hook: materialize failed, spawning without a pointer"
623 );
624 directive
625 }
626 }
627 }
628 Err(err) => {
629 tracing::warn!(
630 %task_id,
631 error = %err,
632 "projection hook: AgentContextView serialize failed, spawning without a pointer"
633 );
634 directive
635 }
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use mlua_swarm::core::agent_context::{
643 TASK_METADATA_KEY, TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY,
644 };
645
646 fn view_with(
653 alias: Option<&str>,
654 project_root: Option<&str>,
655 work_dir: Option<&str>,
656 ) -> AgentContextView {
657 AgentContextView {
658 project_name_alias: alias.map(String::from),
659 project_root: project_root.map(String::from),
660 work_dir: work_dir.map(String::from),
661 ..AgentContextView::default()
662 }
663 }
664
665 #[test]
666 fn directive_omits_project_name_alias_when_none() {
667 let d = default_spawn_directive(
668 "impl-lead",
669 "task-x",
670 "mse-worker-coder",
671 &view_with(None, None, None),
672 None,
673 None,
674 None,
675 );
676 assert!(!d.contains("project_name_alias:"));
677 assert!(!d.contains("LDS Session Alias"));
678 assert!(!d.contains("session_create"));
679 }
680
681 #[test]
682 fn directive_emits_project_name_alias_when_some() {
683 let d = default_spawn_directive(
684 "impl-lead",
685 "task-x",
686 "mse-worker-coder",
687 &view_with(Some("mse-task-7785"), None, None),
688 None,
689 None,
690 None,
691 );
692 assert!(
694 d.contains("project_name_alias: mse-task-7785"),
695 "directive missing project_name_alias header: {d}"
696 );
697 assert!(
699 d.contains("mcp__lds__session_create(root=<working_dir>, alias=\"mse-task-7785\")"),
700 "directive missing session_create reminder: {d}"
701 );
702 assert!(
703 d.contains("LDS Session Alias: mse-task-7785"),
704 "directive missing SubAgent prompt inject line: {d}"
705 );
706 assert!(
708 d.contains("inlined above") || d.contains("complete contract"),
709 "directive should inline rationale rather than point at external doc: {d}"
710 );
711 let forbidden_doc_ref = format!(".{}/CLAUDE.md", "claude");
720 assert!(
721 !d.contains(&forbidden_doc_ref),
722 "directive must not reference {forbidden_doc_ref} (out of MainAI scope): {d}"
723 );
724 }
725
726 #[test]
727 fn directive_omits_data_endpoint_when_none() {
728 let d = default_spawn_directive(
729 "impl-lead",
730 "task-x",
731 "mse-worker-coder",
732 &view_with(None, None, None),
733 None,
734 None,
735 None,
736 );
737 assert!(!d.contains("[Data path endpoint"));
738 assert!(!d.contains("DATA_EMIT"));
739 assert!(!d.contains("DATA_GET"));
740 }
741
742 #[test]
743 fn directive_emits_data_endpoint_when_some() {
744 let base = "http://127.0.0.1:7785";
745 let d = default_spawn_directive(
746 "impl-lead",
747 "task-x",
748 "mse-worker-coder",
749 &view_with(None, None, None),
750 Some(base),
751 None,
752 None,
753 );
754 assert!(
755 d.contains("[Data path endpoint"),
756 "directive missing data endpoint block header: {d}"
757 );
758 assert!(
759 d.contains(&format!("DATA_EMIT: {base}/v1/data/emit")),
760 "directive missing single-mouth emit line: {d}"
761 );
762 assert!(
763 d.contains("Bearer worker_handle or ?token="),
764 "directive missing auth transport hint: {d}"
765 );
766 assert!(
767 d.contains(&format!("DATA_GET: {base}/v1/data/<out_id|out_name>")),
768 "directive missing GET line: {d}"
769 );
770 assert!(
771 !d.contains("emit-auth"),
772 "old split endpoint must not leak into directive: {d}"
773 );
774 assert!(
775 d.contains("bypassing the MainAgent") && d.contains("out_id ref"),
776 "directive should carry the ownership + bypass reasoning: {d}"
777 );
778 }
779
780 #[test]
781 fn directive_carries_declared_subagent_type_and_has_no_fallback() {
782 let d = default_spawn_directive(
783 "impl-lead",
784 "task-x",
785 "mse-worker-coder",
786 &view_with(None, None, None),
787 None,
788 None,
789 None,
790 );
791 assert!(
792 d.contains("subagent_type=\"mse-worker-coder\""),
793 "directive must carry the Blueprint-declared subagent_type literally: {d}"
794 );
795 assert!(
796 d.contains(".claude/agents/mse-worker-coder.md"),
797 "directive must reference the declared subagent's own .md path: {d}"
798 );
799 assert!(
801 !d.contains("general-purpose"),
802 "directive must not fall back to subagent_type=\"general-purpose\": {d}"
803 );
804 assert!(
805 !d.contains("mse-worker\""),
806 "directive must not carry the old hardcoded \"mse-worker\" literal: {d}"
807 );
808 assert!(
809 d.contains("FAIL LOUD"),
810 "directive must instruct the MainAI to fail loud instead of falling back: {d}"
811 );
812 }
813
814 #[test]
820 fn directive_renders_actual_base_url_when_some() {
821 let d = default_spawn_directive(
822 "impl-lead",
823 "task-x",
824 "mse-worker-coder",
825 &view_with(None, None, None),
826 None,
827 Some("http://127.0.0.1:8888"),
828 None,
829 );
830 assert!(
831 d.contains("base_url: http://127.0.0.1:8888"),
832 "directive must render the actual bind literally: {d}"
833 );
834 assert!(
835 !d.contains("mse_doctor"),
836 "no mse_doctor detour when bind is known: {d}"
837 );
838 }
839
840 #[test]
844 fn directive_falls_back_to_mse_doctor_pointer_when_none() {
845 let d = default_spawn_directive(
846 "impl-lead",
847 "task-x",
848 "mse-worker-coder",
849 &view_with(None, None, None),
850 None,
851 None,
852 None,
853 );
854 assert!(
855 d.contains("check with mse_doctor"),
856 "fallback must point at mse_doctor: {d}"
857 );
858 }
859
860 #[test]
864 fn directive_never_contains_stale_example_port_7786() {
865 for base in [
866 None,
867 Some("http://127.0.0.1:7777"),
868 Some("http://192.0.2.1:9000"),
869 ] {
870 let d = default_spawn_directive(
871 "impl-lead",
872 "task-x",
873 "mse-worker-coder",
874 &view_with(Some("mse-task-alias"), None, None),
875 Some("http://127.0.0.1:7785"),
876 base,
877 None,
878 );
879 assert!(
880 !d.contains("7786"),
881 "stale example port 7786 leaked: base={base:?}, d={d}"
882 );
883 }
884 }
885
886 #[test]
892 fn directive_never_contains_stale_tasks_id_route() {
893 let d = default_spawn_directive(
894 "impl-lead",
895 "task-x",
896 "mse-worker-coder",
897 &view_with(None, None, None),
898 None,
899 None,
900 Some("R-abc123"),
901 );
902 assert!(
903 !d.contains("/v1/tasks/{id}") && !d.contains("/v1/tasks/{{id}}"),
904 "stale /v1/tasks/{{id}} observation hint leaked: {d}"
905 );
906 }
907
908 #[test]
911 fn directive_renders_actual_run_id_when_some() {
912 let d = default_spawn_directive(
913 "impl-lead",
914 "task-x",
915 "mse-worker-coder",
916 &view_with(None, None, None),
917 None,
918 None,
919 Some("R-abc123"),
920 );
921 assert!(
922 d.contains("GET <base_url>/v1/runs/R-abc123"),
923 "directive missing real run_id in observation route: {d}"
924 );
925 }
926
927 #[test]
930 fn directive_falls_back_to_run_id_placeholder_when_none() {
931 let d = default_spawn_directive(
932 "impl-lead",
933 "task-x",
934 "mse-worker-coder",
935 &view_with(None, None, None),
936 None,
937 None,
938 None,
939 );
940 assert!(
941 d.contains("GET <base_url>/v1/runs/<run_id>"),
942 "directive missing placeholder observation route: {d}"
943 );
944 }
945
946 #[test]
951 fn directive_omits_project_root_and_work_dir_when_both_none() {
952 let d = default_spawn_directive(
953 "impl-lead",
954 "task-x",
955 "mse-worker-coder",
956 &view_with(None, None, None),
957 None,
958 None,
959 None,
960 );
961 assert!(!d.contains("project_root:"));
962 assert!(!d.contains("work_dir:"));
963 }
964
965 #[test]
968 fn directive_splices_project_root_and_work_dir_when_both_present() {
969 let d = default_spawn_directive(
970 "impl-lead",
971 "task-x",
972 "mse-worker-coder",
973 &view_with(None, Some("/repo"), Some("/repo/work")),
974 None,
975 None,
976 None,
977 );
978 assert!(
979 d.contains("project_root: /repo"),
980 "directive missing project_root header: {d}"
981 );
982 assert!(
983 d.contains("work_dir: /repo/work"),
984 "directive missing work_dir header: {d}"
985 );
986 }
987
988 #[test]
991 fn directive_splices_project_root_only_when_work_dir_absent() {
992 let d = default_spawn_directive(
993 "impl-lead",
994 "task-x",
995 "mse-worker-coder",
996 &view_with(None, Some("/repo"), None),
997 None,
998 None,
999 None,
1000 );
1001 assert!(
1002 d.contains("project_root: /repo"),
1003 "directive missing project_root header: {d}"
1004 );
1005 assert!(!d.contains("work_dir:"));
1006 }
1007
1008 #[test]
1015 fn directive_splices_task_metadata_when_some() {
1016 let view = AgentContextView {
1017 task_metadata: Some(serde_json::json!({"issue": 20})),
1018 ..view_with(None, Some("/repo"), None)
1019 };
1020 let d = default_spawn_directive(
1021 "impl-lead",
1022 "task-x",
1023 "mse-worker-coder",
1024 &view,
1025 None,
1026 None,
1027 None,
1028 );
1029 assert!(
1030 d.contains(r#"task_metadata: {"issue":20}"#),
1031 "directive missing task_metadata header: {d}"
1032 );
1033 assert!(d.contains("project_root: /repo"));
1035 }
1036
1037 #[test]
1041 fn directive_omits_task_metadata_when_none() {
1042 let d = default_spawn_directive(
1043 "impl-lead",
1044 "task-x",
1045 "mse-worker-coder",
1046 &view_with(None, None, None),
1047 None,
1048 None,
1049 None,
1050 );
1051 assert!(!d.contains("task_metadata:"));
1052 }
1053
1054 fn test_ctx(task_id: &str) -> mlua_swarm::Ctx {
1057 mlua_swarm::Ctx::new(mlua_swarm::StepId::parse(task_id).unwrap(), 1, "a")
1058 }
1059
1060 fn test_worker_binding() -> mlua_swarm::WorkerBinding {
1061 mlua_swarm::WorkerBinding {
1062 variant: "test-variant".into(),
1063 tools: vec![],
1064 }
1065 }
1066
1067 fn test_cap_token() -> mlua_swarm::CapToken {
1068 mlua_swarm::CapToken {
1069 agent_id: "a".into(),
1070 role: mlua_swarm::Role::Worker,
1071 scopes: vec!["*".into()],
1072 issued_at: 0,
1073 expire_at: u64::MAX / 2,
1074 max_uses: None,
1075 nonce: "test-nonce".into(),
1076 sig_hex: "".into(),
1077 }
1078 }
1079
1080 #[tokio::test]
1086 async fn spawn_halt_reply_lands_as_ok_worker_result_with_marker() {
1087 use mlua_swarm::Operator;
1088 use tokio::sync::mpsc;
1089
1090 let (tx, mut rx) = mpsc::unbounded_channel();
1091 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1092 SessionId::parse("S-halt").unwrap(),
1093 tx,
1094 None,
1095 ));
1096
1097 let session_bg = session.clone();
1100 let handle = tokio::spawn(async move {
1101 session_bg
1102 .execute(
1103 &test_ctx("ST-halt"),
1104 None,
1105 "".into(),
1106 Some(test_worker_binding()),
1107 test_cap_token(),
1108 )
1109 .await
1110 });
1111
1112 let sent = rx.recv().await.expect("Spawn sent");
1113 let req_id = match sent {
1114 ServerMsg::Spawn { req_id, .. } => req_id,
1115 other => panic!("expected Spawn, got {other:?}"),
1116 };
1117
1118 session
1119 .resolve_pending(
1120 &req_id,
1121 PendingReply::SpawnHalt {
1122 value: serde_json::json!({"partial": "abc"}),
1123 reason: Some("shape verified".into()),
1124 },
1125 )
1126 .await;
1127
1128 let result = handle.await.expect("join").expect("execute Ok");
1129 assert!(
1130 result.ok,
1131 "spawn_halt must land as ok=true (normal termination), got: {result:?}"
1132 );
1133 assert_eq!(result.value["halted"], true);
1134 assert_eq!(result.value["reason"], "shape verified");
1135 assert_eq!(result.value["value"], serde_json::json!({"partial": "abc"}));
1136 }
1137
1138 #[tokio::test]
1141 async fn spawn_ack_with_error_still_lands_as_worker_error() {
1142 use mlua_swarm::{Operator, WorkerError};
1143 use tokio::sync::mpsc;
1144
1145 let (tx, mut rx) = mpsc::unbounded_channel();
1146 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1147 SessionId::parse("S-err").unwrap(),
1148 tx,
1149 None,
1150 ));
1151
1152 let session_bg = session.clone();
1153 let handle = tokio::spawn(async move {
1154 session_bg
1155 .execute(
1156 &test_ctx("ST-err"),
1157 None,
1158 "".into(),
1159 Some(test_worker_binding()),
1160 test_cap_token(),
1161 )
1162 .await
1163 });
1164
1165 let sent = rx.recv().await.expect("Spawn sent");
1166 let req_id = match sent {
1167 ServerMsg::Spawn { req_id, .. } => req_id,
1168 other => panic!("expected Spawn, got {other:?}"),
1169 };
1170
1171 session
1172 .resolve_pending(
1173 &req_id,
1174 PendingReply::SpawnAck {
1175 value: serde_json::json!({}),
1176 ok: false,
1177 error: Some("real crash".into()),
1178 },
1179 )
1180 .await;
1181
1182 let err = handle.await.expect("join").expect_err("must be error");
1183 assert!(matches!(err, WorkerError::Failed(msg) if msg.contains("real crash")));
1184 }
1185
1186 #[tokio::test]
1193 async fn execute_splices_project_root_and_work_dir_from_ctx_meta_runtime() {
1194 use mlua_swarm::Operator;
1195 use tokio::sync::mpsc;
1196
1197 let (tx, mut rx) = mpsc::unbounded_channel();
1198 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1199 SessionId::parse("S-ctxroot").unwrap(),
1200 tx,
1201 None,
1202 ));
1203
1204 let mut ctx = test_ctx("ST-ctxroot");
1205 ctx.meta.runtime.insert(
1206 TASK_PROJECT_ROOT_KEY.to_string(),
1207 serde_json::json!("/repo"),
1208 );
1209 ctx.meta.runtime.insert(
1210 TASK_WORK_DIR_KEY.to_string(),
1211 serde_json::json!("/repo/work"),
1212 );
1213
1214 let session_bg = session.clone();
1215 let handle = tokio::spawn(async move {
1216 session_bg
1217 .execute(
1218 &ctx,
1219 None,
1220 "".into(),
1221 Some(test_worker_binding()),
1222 test_cap_token(),
1223 )
1224 .await
1225 });
1226
1227 let sent = rx.recv().await.expect("Spawn sent");
1228 let req_id = match sent {
1229 ServerMsg::Spawn {
1230 req_id, directive, ..
1231 } => {
1232 let directive = directive.as_str();
1236 assert!(
1237 directive.contains("project_root: /repo"),
1238 "directive missing project_root splice: {directive}"
1239 );
1240 assert!(
1241 directive.contains("work_dir: /repo/work"),
1242 "directive missing work_dir splice: {directive}"
1243 );
1244 req_id
1245 }
1246 other => panic!("expected Spawn, got {other:?}"),
1247 };
1248
1249 session
1250 .resolve_pending(
1251 &req_id,
1252 PendingReply::SpawnAck {
1253 value: serde_json::json!({}),
1254 ok: true,
1255 error: None,
1256 },
1257 )
1258 .await;
1259 handle.await.expect("join").expect("execute Ok");
1260 }
1261
1262 #[tokio::test]
1267 async fn execute_splices_project_root_only_when_ctx_meta_runtime_partial() {
1268 use mlua_swarm::Operator;
1269 use tokio::sync::mpsc;
1270
1271 let (tx, mut rx) = mpsc::unbounded_channel();
1272 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1273 SessionId::parse("S-ctxpartial").unwrap(),
1274 tx,
1275 None,
1276 ));
1277
1278 let mut ctx = test_ctx("ST-ctxpartial");
1279 ctx.meta.runtime.insert(
1280 TASK_PROJECT_ROOT_KEY.to_string(),
1281 serde_json::json!("/repo"),
1282 );
1283
1284 let session_bg = session.clone();
1285 let handle = tokio::spawn(async move {
1286 session_bg
1287 .execute(
1288 &ctx,
1289 None,
1290 "".into(),
1291 Some(test_worker_binding()),
1292 test_cap_token(),
1293 )
1294 .await
1295 });
1296
1297 let sent = rx.recv().await.expect("Spawn sent");
1298 let req_id = match sent {
1299 ServerMsg::Spawn {
1300 req_id, directive, ..
1301 } => {
1302 let directive = directive.as_str();
1303 assert!(
1304 directive.contains("project_root: /repo"),
1305 "directive missing project_root splice: {directive}"
1306 );
1307 assert!(!directive.contains("work_dir:"));
1308 req_id
1309 }
1310 other => panic!("expected Spawn, got {other:?}"),
1311 };
1312
1313 session
1314 .resolve_pending(
1315 &req_id,
1316 PendingReply::SpawnAck {
1317 value: serde_json::json!({}),
1318 ok: true,
1319 error: None,
1320 },
1321 )
1322 .await;
1323 handle.await.expect("join").expect("execute Ok");
1324 }
1325
1326 #[tokio::test]
1330 async fn execute_omits_project_root_and_work_dir_when_ctx_meta_runtime_absent() {
1331 use mlua_swarm::Operator;
1332 use tokio::sync::mpsc;
1333
1334 let (tx, mut rx) = mpsc::unbounded_channel();
1335 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1336 SessionId::parse("S-ctxabsent").unwrap(),
1337 tx,
1338 None,
1339 ));
1340
1341 let ctx = test_ctx("ST-ctxabsent");
1342
1343 let session_bg = session.clone();
1344 let handle = tokio::spawn(async move {
1345 session_bg
1346 .execute(
1347 &ctx,
1348 None,
1349 "".into(),
1350 Some(test_worker_binding()),
1351 test_cap_token(),
1352 )
1353 .await
1354 });
1355
1356 let sent = rx.recv().await.expect("Spawn sent");
1357 let req_id = match sent {
1358 ServerMsg::Spawn {
1359 req_id, directive, ..
1360 } => {
1361 let directive = directive.as_str();
1362 assert!(!directive.contains("project_root:"));
1363 assert!(!directive.contains("work_dir:"));
1364 req_id
1365 }
1366 other => panic!("expected Spawn, got {other:?}"),
1367 };
1368
1369 session
1370 .resolve_pending(
1371 &req_id,
1372 PendingReply::SpawnAck {
1373 value: serde_json::json!({}),
1374 ok: true,
1375 error: None,
1376 },
1377 )
1378 .await;
1379 handle.await.expect("join").expect("execute Ok");
1380 }
1381
1382 #[tokio::test]
1388 async fn execute_splices_task_metadata_from_ctx_meta_runtime() {
1389 use mlua_swarm::Operator;
1390 use tokio::sync::mpsc;
1391
1392 let (tx, mut rx) = mpsc::unbounded_channel();
1393 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1394 SessionId::parse("S-ctxmeta").unwrap(),
1395 tx,
1396 None,
1397 ));
1398
1399 let mut ctx = test_ctx("ST-ctxmeta");
1400 ctx.meta.runtime.insert(
1401 TASK_METADATA_KEY.to_string(),
1402 serde_json::json!({"issue": 20}),
1403 );
1404
1405 let session_bg = session.clone();
1406 let handle = tokio::spawn(async move {
1407 session_bg
1408 .execute(
1409 &ctx,
1410 None,
1411 "".into(),
1412 Some(test_worker_binding()),
1413 test_cap_token(),
1414 )
1415 .await
1416 });
1417
1418 let sent = rx.recv().await.expect("Spawn sent");
1419 let req_id = match sent {
1420 ServerMsg::Spawn {
1421 req_id, directive, ..
1422 } => {
1423 let directive = directive.as_str();
1424 assert!(
1425 directive.contains(r#"task_metadata: {"issue":20}"#),
1426 "directive missing task_metadata splice: {directive}"
1427 );
1428 req_id
1429 }
1430 other => panic!("expected Spawn, got {other:?}"),
1431 };
1432
1433 session
1434 .resolve_pending(
1435 &req_id,
1436 PendingReply::SpawnAck {
1437 value: serde_json::json!({}),
1438 ok: true,
1439 error: None,
1440 },
1441 )
1442 .await;
1443 handle.await.expect("join").expect("execute Ok");
1444 }
1445
1446 #[test]
1452 fn with_task_directive_splices_string_seed_verbatim() {
1453 let directive = default_spawn_directive_with_task_directive(
1454 "impl-lead",
1455 "task-x",
1456 "mse-worker-coder",
1457 &view_with(None, None, None),
1458 None,
1459 None,
1460 None,
1461 &serde_json::json!("do the thing"),
1462 );
1463 let text = directive.as_str();
1464 assert!(
1465 text.contains("task_directive: do the thing"),
1466 "missing task_directive line for a String seed: {text}"
1467 );
1468 }
1469
1470 #[test]
1474 fn with_task_directive_renders_object_seed_as_json_literal() {
1475 let directive = default_spawn_directive_with_task_directive(
1476 "impl-lead",
1477 "task-x",
1478 "mse-worker-coder",
1479 &view_with(None, None, None),
1480 None,
1481 None,
1482 None,
1483 &serde_json::json!({"key": "value"}),
1484 );
1485 let text = directive.as_str();
1486 assert!(
1487 text.contains(r#"task_directive: {"key":"value"}"#),
1488 "missing JSON-literal task_directive line for an Object seed: {text}"
1489 );
1490 }
1491
1492 #[test]
1496 fn with_task_directive_omits_line_when_null() {
1497 let wrapped = default_spawn_directive_with_task_directive(
1498 "impl-lead",
1499 "task-x",
1500 "mse-worker-coder",
1501 &view_with(None, None, None),
1502 None,
1503 None,
1504 None,
1505 &serde_json::Value::Null,
1506 );
1507 let plain = default_spawn_directive(
1508 "impl-lead",
1509 "task-x",
1510 "mse-worker-coder",
1511 &view_with(None, None, None),
1512 None,
1513 None,
1514 None,
1515 );
1516 assert_eq!(
1517 wrapped,
1518 serde_json::Value::String(plain),
1519 "Value::Null seed must not add a task_directive line"
1520 );
1521 }
1522
1523 #[tokio::test]
1530 async fn execute_splices_json_literal_task_directive_for_object_seed() {
1531 use mlua_swarm::Operator;
1532 use tokio::sync::mpsc;
1533
1534 let (tx, mut rx) = mpsc::unbounded_channel();
1535 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1536 SessionId::parse("S-objseed").unwrap(),
1537 tx,
1538 None,
1539 ));
1540
1541 let ctx = test_ctx("ST-objseed");
1542 let rendered_prompt = serde_json::json!({"key": "value"});
1547
1548 let session_bg = session.clone();
1549 let handle = tokio::spawn(async move {
1550 session_bg
1551 .execute(
1552 &ctx,
1553 None,
1554 rendered_prompt,
1555 Some(test_worker_binding()),
1556 test_cap_token(),
1557 )
1558 .await
1559 });
1560
1561 let sent = rx.recv().await.expect("Spawn sent");
1562 let req_id = match sent {
1563 ServerMsg::Spawn {
1564 req_id, directive, ..
1565 } => {
1566 let directive = directive.as_str();
1567 assert!(
1568 directive.contains(r#"task_directive: {"key":"value"}"#),
1569 "directive missing JSON-literal task_directive splice: {directive}"
1570 );
1571 req_id
1572 }
1573 other => panic!("expected Spawn, got {other:?}"),
1574 };
1575
1576 session
1577 .resolve_pending(
1578 &req_id,
1579 PendingReply::SpawnAck {
1580 value: serde_json::json!({}),
1581 ok: true,
1582 error: None,
1583 },
1584 )
1585 .await;
1586 handle.await.expect("join").expect("execute Ok");
1587 }
1588
1589 #[tokio::test]
1595 async fn execute_with_work_dir_appends_ctx_projection_pointer_and_materializes_file() {
1596 use mlua_swarm::Operator;
1597 use tokio::sync::mpsc;
1598
1599 let dir = tempfile::TempDir::new().unwrap();
1600 let mut ctx = test_ctx("ST-proj-1");
1601 ctx.meta.runtime.insert(
1602 TASK_WORK_DIR_KEY.to_string(),
1603 Value::String(dir.path().to_string_lossy().into_owned()),
1604 );
1605
1606 let (tx, mut rx) = mpsc::unbounded_channel();
1607 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1608 SessionId::parse("S-proj-1").unwrap(),
1609 tx,
1610 None,
1611 ));
1612
1613 let session_bg = session.clone();
1614 let handle = tokio::spawn(async move {
1615 session_bg
1616 .execute(
1617 &ctx,
1618 None,
1619 "".into(),
1620 Some(test_worker_binding()),
1621 test_cap_token(),
1622 )
1623 .await
1624 });
1625
1626 let sent = rx.recv().await.expect("Spawn sent");
1627 let req_id = match sent {
1628 ServerMsg::Spawn {
1629 req_id, directive, ..
1630 } => {
1631 assert!(
1632 directive.contains("ctx_projection:"),
1633 "directive missing ctx_projection pointer line: {directive}"
1634 );
1635 assert!(
1642 !directive.contains("ctx_step_dir:"),
1643 "directive must not carry the retired ctx_step_dir line: {directive}"
1644 );
1645 req_id
1646 }
1647 other => panic!("expected Spawn, got {other:?}"),
1648 };
1649
1650 session
1651 .resolve_pending(
1652 &req_id,
1653 PendingReply::SpawnAck {
1654 value: serde_json::json!({}),
1655 ok: true,
1656 error: None,
1657 },
1658 )
1659 .await;
1660 handle.await.expect("join").expect("execute Ok");
1661
1662 let expected_file = dir.path().join("workspace/tasks/ST-proj-1/ctx/_ctx.md");
1663 assert!(
1664 expected_file.exists(),
1665 "materialized projection file missing at {expected_file:?}"
1666 );
1667 }
1668
1669 #[tokio::test]
1674 async fn execute_without_work_dir_spawns_without_ctx_projection_pointer() {
1675 use mlua_swarm::Operator;
1676 use tokio::sync::mpsc;
1677
1678 let (tx, mut rx) = mpsc::unbounded_channel();
1679 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1680 SessionId::parse("S-proj-2").unwrap(),
1681 tx,
1682 None,
1683 ));
1684
1685 let session_bg = session.clone();
1686 let handle = tokio::spawn(async move {
1687 session_bg
1688 .execute(
1689 &test_ctx("ST-proj-2"),
1690 None,
1691 "".into(),
1692 Some(test_worker_binding()),
1693 test_cap_token(),
1694 )
1695 .await
1696 });
1697
1698 let sent = rx.recv().await.expect("Spawn sent");
1699 let req_id = match sent {
1700 ServerMsg::Spawn {
1701 req_id, directive, ..
1702 } => {
1703 assert!(
1704 !directive.contains("ctx_projection:"),
1705 "directive must not carry a pointer line when work_dir is absent \
1706 (fallback): {directive}"
1707 );
1708 req_id
1709 }
1710 other => panic!("expected Spawn, got {other:?}"),
1711 };
1712
1713 session
1714 .resolve_pending(
1715 &req_id,
1716 PendingReply::SpawnAck {
1717 value: serde_json::json!({}),
1718 ok: true,
1719 error: None,
1720 },
1721 )
1722 .await;
1723 handle
1724 .await
1725 .expect("join")
1726 .expect("execute Ok — a materialize skip must not fail the spawn");
1727 }
1728
1729 #[tokio::test]
1740 async fn execute_with_project_root_only_appends_ctx_projection_pointer_default_placement() {
1741 use mlua_swarm::Operator;
1742 use tokio::sync::mpsc;
1743
1744 let dir = tempfile::TempDir::new().unwrap();
1745 let mut ctx = test_ctx("ST-proj-3");
1746 ctx.meta.runtime.insert(
1747 TASK_PROJECT_ROOT_KEY.to_string(),
1748 Value::String(dir.path().to_string_lossy().into_owned()),
1749 );
1750
1751 let (tx, mut rx) = mpsc::unbounded_channel();
1752 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1753 SessionId::parse("S-proj-3").unwrap(),
1754 tx,
1755 None,
1756 ));
1757
1758 let session_bg = session.clone();
1759 let handle = tokio::spawn(async move {
1760 session_bg
1761 .execute(
1762 &ctx,
1763 None,
1764 "".into(),
1765 Some(test_worker_binding()),
1766 test_cap_token(),
1767 )
1768 .await
1769 });
1770
1771 let sent = rx.recv().await.expect("Spawn sent");
1772 let req_id = match sent {
1773 ServerMsg::Spawn {
1774 req_id, directive, ..
1775 } => {
1776 assert!(
1777 directive.contains("ctx_projection:"),
1778 "work_dir absent must still fall back to project_root: {directive}"
1779 );
1780 req_id
1781 }
1782 other => panic!("expected Spawn, got {other:?}"),
1783 };
1784
1785 session
1786 .resolve_pending(
1787 &req_id,
1788 PendingReply::SpawnAck {
1789 value: serde_json::json!({}),
1790 ok: true,
1791 error: None,
1792 },
1793 )
1794 .await;
1795 handle.await.expect("join").expect("execute Ok");
1796
1797 let expected_file = dir.path().join("workspace/tasks/ST-proj-3/ctx/_ctx.md");
1798 assert!(
1799 expected_file.exists(),
1800 "materialized projection file missing at {expected_file:?}"
1801 );
1802 }
1803
1804 #[tokio::test]
1812 async fn execute_with_custom_projection_placement_uses_declared_root_and_template() {
1813 use mlua_swarm::core::projection_placement::{ProjectionPlacement, RootPreference};
1814 use mlua_swarm::Operator;
1815 use tokio::sync::mpsc;
1816
1817 let work_dir = tempfile::TempDir::new().unwrap();
1818 let project_root = tempfile::TempDir::new().unwrap();
1819 let mut ctx = test_ctx("ST-proj-4");
1820 ctx.meta.runtime.insert(
1821 TASK_WORK_DIR_KEY.to_string(),
1822 Value::String(work_dir.path().to_string_lossy().into_owned()),
1823 );
1824 ctx.meta.runtime.insert(
1825 TASK_PROJECT_ROOT_KEY.to_string(),
1826 Value::String(project_root.path().to_string_lossy().into_owned()),
1827 );
1828 let placement = ProjectionPlacement {
1829 root_preference: RootPreference::ProjectRoot,
1830 dir_template: "custom/{task_id}/out".to_string(),
1831 };
1832 ctx.meta.runtime.insert(
1833 PROJECTION_PLACEMENT_KEY.to_string(),
1834 serde_json::to_value(&placement).expect("placement serializes"),
1835 );
1836
1837 let (tx, mut rx) = mpsc::unbounded_channel();
1838 let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1839 SessionId::parse("S-proj-4").unwrap(),
1840 tx,
1841 None,
1842 ));
1843
1844 let session_bg = session.clone();
1845 let handle = tokio::spawn(async move {
1846 session_bg
1847 .execute(
1848 &ctx,
1849 None,
1850 "".into(),
1851 Some(test_worker_binding()),
1852 test_cap_token(),
1853 )
1854 .await
1855 });
1856
1857 let sent = rx.recv().await.expect("Spawn sent");
1858 let req_id = match sent {
1859 ServerMsg::Spawn {
1860 req_id, directive, ..
1861 } => {
1862 assert!(
1863 directive.contains("ctx_projection:"),
1864 "directive missing ctx_projection pointer line: {directive}"
1865 );
1866 req_id
1867 }
1868 other => panic!("expected Spawn, got {other:?}"),
1869 };
1870
1871 session
1872 .resolve_pending(
1873 &req_id,
1874 PendingReply::SpawnAck {
1875 value: serde_json::json!({}),
1876 ok: true,
1877 error: None,
1878 },
1879 )
1880 .await;
1881 handle.await.expect("join").expect("execute Ok");
1882
1883 let expected_file = project_root.path().join("custom/ST-proj-4/out/_ctx.md");
1884 assert!(
1885 expected_file.exists(),
1886 "materialized projection file missing at custom placement target {expected_file:?}"
1887 );
1888 let unexpected_file = work_dir
1889 .path()
1890 .join("workspace/tasks/ST-proj-4/ctx/_ctx.md");
1891 assert!(
1892 !unexpected_file.exists(),
1893 "declared root_preference=ProjectRoot must not fall back to work_dir: {unexpected_file:?}"
1894 );
1895 }
1896}