Skip to main content

mlua_swarm_server/operator_ws/
session.rs

1//! `WSOperatorSession`: 1 sid = 1 session = 3 traits co-hosted (`SeniorBridge` /
2//! `SpawnHook` / `Operator`). Registered simultaneously into 3 registries under
3//! the same sid — the canonical pattern where 1 WS connection covers all 3
4//! faces of the Operator role (judgment / observation / execution).
5//!
6//! `tx` is a `Mutex<Option<Sender>>`: `None` on disconnect, swappable to
7//! `Some(new_tx)` on reconnect. The `pending` `HashMap` persists on the session
8//! side, so a client holding answer/ack values across a disconnect can reconnect
9//! and resend them.
10//!
11//! For the detailed S↔C message flow, see the overview figure in `mod.rs`.
12
13use async_trait::async_trait;
14use mlua_swarm::{
15    CapToken, Ctx, Operator, SeniorBridge, SpawnHook, TaskId, WorkerBinding, WorkerError,
16    WorkerResult,
17};
18use serde_json::Value;
19use std::collections::HashMap;
20use tokio::sync::{mpsc, oneshot, Mutex};
21
22use super::protocol::{current_parent_req_id, PendingReply, ServerMsg};
23
24/// 1 sid = 1 session. Looked up by sid in the `operator_sessions` store on reconnect.
25pub struct WSOperatorSession {
26    sid: String,
27    /// The current mpsc sender on the write path. `None` on disconnect;
28    /// swapped to `Some(new_tx)` on reconnect.
29    tx: Mutex<Option<mpsc::UnboundedSender<ServerMsg>>>,
30    /// `req_id` → pending oneshot. Resolved when `answer` / `hook_ack` /
31    /// `spawn_ack` arrives.
32    pending: Mutex<HashMap<String, oneshot::Sender<PendingReply>>>,
33    /// Public HTTP base URL the server is reachable at (from
34    /// `AppState.base_url`, sourced from the binary at boot time).
35    /// Rendered literally into the Spawn `directive`'s `base_url` line
36    /// when `Some`; `None` falls back to a `mse_doctor`-pointer
37    /// placeholder (issue #8).
38    base_url: Option<std::sync::Arc<str>>,
39}
40
41impl WSOperatorSession {
42    /// `login.rs::handle_operator_socket` is the sole constructor call site.
43    /// Auth (Bearer token match) is checked there against `OperatorSessionEntry.token`
44    /// *before* upgrade — this struct no longer carries its own auth_token copy.
45    ///
46    /// `base_url` is the server's public HTTP root (e.g.
47    /// `"http://127.0.0.1:7777"`), threaded from `AppState.base_url`.
48    /// When `Some`, it is rendered literally into Spawn directives
49    /// (issue #8); `None` falls back to a `mse_doctor`-pointer
50    /// placeholder.
51    pub(super) fn new_with_base_url(
52        sid: String,
53        tx: mpsc::UnboundedSender<ServerMsg>,
54        base_url: Option<std::sync::Arc<str>>,
55    ) -> Self {
56        Self {
57            sid,
58            tx: Mutex::new(Some(tx)),
59            pending: Mutex::new(HashMap::new()),
60            base_url,
61        }
62    }
63
64    /// Swaps in a new tx on reconnect. Expected to be called only from the handler side.
65    pub(super) async fn replace_tx(&self, new_tx: mpsc::UnboundedSender<ServerMsg>) {
66        *self.tx.lock().await = Some(new_tx);
67    }
68
69    /// Clears tx to `None` on disconnect. Expected to be called only from the handler side.
70    pub(crate) async fn clear_tx(&self) {
71        *self.tx.lock().await = None;
72    }
73
74    /// Resolves the pending oneshot when a `ClientMsg` arrives on the handler's
75    /// read task. If `req_id` is not registered, no-op (= silently drops unknown acks).
76    pub(super) async fn resolve_pending(&self, req_id: &str, reply: PendingReply) {
77        if let Some(otx) = self.pending.lock().await.remove(req_id) {
78            let _ = otx.send(reply);
79        }
80    }
81
82    /// Inserts an entry into pending, sends S→C, and waits for the reply. No
83    /// timeout in v1.5 (= an ask during a disconnect immediately returns `Err`
84    /// on send failure; reconnect-wait behavior is v2).
85    async fn send_and_await(&self, req_id: String, msg: ServerMsg) -> Result<PendingReply, String> {
86        let (otx, orx) = oneshot::channel::<PendingReply>();
87        self.pending.lock().await.insert(req_id.clone(), otx);
88
89        // Fetch `tx` and send. When None, we are disconnected — fail fast.
90        let send_result = {
91            let guard = self.tx.lock().await;
92            match guard.as_ref() {
93                Some(tx) => tx
94                    .send(msg)
95                    .map_err(|_| "ws send channel closed".to_string()),
96                None => Err("ws operator disconnected".to_string()),
97            }
98        };
99        if let Err(e) = send_result {
100            self.pending.lock().await.remove(&req_id);
101            return Err(e);
102        }
103
104        orx.await
105            .map_err(|_| "ws operator: oneshot cancelled (= reply path closed)".to_string())
106    }
107
108    /// Fire-and-forget send for `after` (= no reply expected).
109    async fn send_oneway(&self, msg: ServerMsg) -> Result<(), String> {
110        let guard = self.tx.lock().await;
111        match guard.as_ref() {
112            Some(tx) => tx
113                .send(msg)
114                .map_err(|_| "ws send channel closed".to_string()),
115            None => Err("ws operator disconnected".to_string()),
116        }
117    }
118}
119
120#[async_trait]
121impl SeniorBridge for WSOperatorSession {
122    async fn ask(&self, task_id: &TaskId, question: Value) -> Result<Value, String> {
123        let req_id = format!("{}-ask-{}", self.sid, uuid::Uuid::new_v4());
124        let msg = ServerMsg::Ask {
125            req_id: req_id.clone(),
126            parent_req_id: current_parent_req_id(),
127            task_id: task_id.0.clone(),
128            question,
129        };
130        match self.send_and_await(req_id, msg).await? {
131            PendingReply::Answer(v) => Ok(v),
132            PendingReply::HookAck { .. } => {
133                Err("ws operator: unexpected hook_ack reply to ask".into())
134            }
135            PendingReply::SpawnAck { .. } => {
136                Err("ws operator: unexpected spawn_ack reply to ask".into())
137            }
138            PendingReply::SpawnHalt { .. } => {
139                Err("ws operator: unexpected spawn_halt reply to ask".into())
140            }
141        }
142    }
143}
144
145#[async_trait]
146impl SpawnHook for WSOperatorSession {
147    async fn before(&self, ctx: &Ctx) -> Result<(), String> {
148        let req_id = format!("{}-hb-{}", self.sid, uuid::Uuid::new_v4());
149        let msg = ServerMsg::HookBefore {
150            req_id: req_id.clone(),
151            parent_req_id: current_parent_req_id(),
152            task_id: ctx.task_id.0.clone(),
153            agent: ctx.agent.clone(),
154            attempt: ctx.attempt,
155        };
156        match self.send_and_await(req_id, msg).await? {
157            PendingReply::HookAck { ok: true, .. } => Ok(()),
158            PendingReply::HookAck { ok: false, reason } => {
159                Err(reason.unwrap_or_else(|| "ws operator: spawn rejected".into()))
160            }
161            PendingReply::Answer(_) => {
162                Err("ws operator: unexpected answer reply to hook_before".into())
163            }
164            PendingReply::SpawnAck { .. } => {
165                Err("ws operator: unexpected spawn_ack reply to hook_before".into())
166            }
167            PendingReply::SpawnHalt { .. } => {
168                Err("ws operator: unexpected spawn_halt reply to hook_before".into())
169            }
170        }
171    }
172
173    async fn after(&self, ctx: &Ctx, result: &Value) -> Result<(), String> {
174        let req_id = format!("{}-ha-{}", self.sid, uuid::Uuid::new_v4());
175        let msg = ServerMsg::HookAfter {
176            req_id,
177            parent_req_id: current_parent_req_id(),
178            task_id: ctx.task_id.0.clone(),
179            agent: ctx.agent.clone(),
180            attempt: ctx.attempt,
181            result: result.clone(),
182        };
183        // `after` is fire-and-forget — swallow send failures.
184        let _ = self.send_oneway(msg).await;
185        Ok(())
186    }
187}
188
189#[async_trait]
190impl Operator for WSOperatorSession {
191    /// Thin control channel impl (the Spawn thin-control axis): `system` / `prompt`
192    /// have already been baked into engine state on the server side
193    /// (= `bake_worker_system_prompt` in `OperatorSpawner.spawn` + the existing
194    /// `fetch_prompt` path). This impl encodes `worker_token` and hands it to
195    /// the MainAI in a single Spawn message; the SubAgent then hits
196    /// `/v1/worker/prompt` + `/v1/worker/result` itself over HTTP. The `system`
197    /// / `prompt` arguments are intentionally **not used here** (= heavy payloads
198    /// are not carried on WS — thin-path discipline).
199    ///
200    /// The SubAgent's result post (= HTTP POST `/v1/worker/result`) appends
201    /// `Final` to `output_tail`; when the MainAI returns `SpawnAck`, this
202    /// `execute` returns `WorkerResult` and control returns to the dispatch path.
203    ///
204    /// `worker` is required (see `requires_worker_binding`) — the compile-time
205    /// gate in `OperatorSpawnerFactory::build` is the primary defense, but a
206    /// `None` can still reach here on paths that bypass compilation (e.g. an
207    /// operator-sid-pin path). This runtime check is the defensive second
208    /// layer: fail the task loud rather than silently degrade to the old
209    /// hardcoded `"mse-worker"` literal.
210    async fn execute(
211        &self,
212        ctx: &Ctx,
213        _system: Option<String>,
214        _prompt: String,
215        worker: Option<WorkerBinding>,
216        worker_token: CapToken,
217    ) -> Result<WorkerResult, WorkerError> {
218        let Some(worker) = worker else {
219            return Err(WorkerError::Failed(format!(
220                "agent '{}' has no worker_binding; WS thin-path requires one \
221                 (Blueprint AgentDef.profile.worker_binding)",
222                ctx.agent
223            )));
224        };
225        let req_id = format!("{}-spawn-{}", self.sid, uuid::Uuid::new_v4());
226        let worker_handle = ctx
227            .meta
228            .runtime
229            .get("worker_handle")
230            .and_then(|v| v.as_str())
231            .map(|s| s.to_string());
232        let project_name_alias = ctx
233            .meta
234            .runtime
235            .get("project_name_alias")
236            .and_then(|v| v.as_str());
237        let data_sink_endpoint = ctx
238            .meta
239            .runtime
240            .get("data_sink_endpoint")
241            .and_then(|v| v.as_str());
242        let directive = default_spawn_directive(
243            &ctx.agent,
244            &ctx.task_id.0,
245            &worker.variant,
246            project_name_alias,
247            data_sink_endpoint,
248            self.base_url.as_deref(),
249        );
250        let msg = ServerMsg::Spawn {
251            req_id: req_id.clone(),
252            parent_req_id: current_parent_req_id(),
253            task_id: ctx.task_id.0.clone(),
254            agent: ctx.agent.clone(),
255            attempt: ctx.attempt,
256            capability_token: worker_token.encode(),
257            worker_handle,
258            worker: Some(worker),
259            directive,
260        };
261        match self.send_and_await(req_id, msg).await {
262            Ok(PendingReply::SpawnAck {
263                value,
264                ok,
265                error: None,
266            }) => Ok(WorkerResult { value, ok }),
267            Ok(PendingReply::SpawnAck {
268                error: Some(msg), ..
269            }) => Err(WorkerError::Failed(msg)),
270            // `spawn_halt` (issue #7): controlled halt. Return
271            // `Ok(WorkerResult { ok: true, value: halt_marker })` so the
272            // step lands as a normal termination rather than a
273            // `WorkerError::Failed` — log stays `info`, downstream retry
274            // logic doesn't fire. The halt marker carries the caller's
275            // partial value and reason string in a fixed shape.
276            Ok(PendingReply::SpawnHalt { value, reason }) => {
277                let marker = serde_json::json!({
278                    "halted": true,
279                    "reason": reason,
280                    "value": value,
281                });
282                Ok(WorkerResult {
283                    value: marker,
284                    ok: true,
285                })
286            }
287            Ok(_) => Err(WorkerError::Failed(
288                "ws operator: unexpected non-spawn reply".into(),
289            )),
290            Err(e) => Err(WorkerError::Failed(format!("ws operator spawn: {e}"))),
291        }
292    }
293
294    fn requires_worker_binding(&self) -> bool {
295        true
296    }
297}
298
299/// Literal instruction text for the MainAI (= WS Client = Operator role). Fix
300/// for observation #7.
301///
302/// Minimal hand-off form parallel to /orch (agent_primitive): sends an
303/// `[agent_primitive dispatch=@<agent>]` marker + worker endpoint + auth +
304/// task_id in the payload; the MainAI **kicks a SubAgent by specifying AgentId +
305/// Token** and **forwards the return string verbatim into `SpawnAck.value`**.
306///
307/// The detailed instructions for the SubAgent are consolidated into the
308/// agent.md `system` (= the body fetched by `GET /v1/worker/prompt`); the
309/// directive is narrowed to the minimum routing information.
310///
311/// # `project_name_alias` literal expansion
312///
313/// When the caller sets `Blueprint.metadata.project_name_alias = Some(a)`
314/// (schema field defined in `mlua-swarm-blueprint-schema::BlueprintMetadata`),
315/// the value flows into `ctx.meta.runtime["project_name_alias"]` via the
316/// `ProjectNameAliasLayer` SpawnerLayer (see
317/// `mlua_swarm::middleware::project_name_alias`). This function
318/// then expands the alias **literally** into the Spawn directive text — as the
319/// `project_name_alias: {a}` header line and as the "LDS Session Alias" mandatory
320/// reminder block for the MainAI. The engine itself performs no other action on
321/// the alias; the expansion here is what the MainAI actually reads.
322///
323/// # `subagent_type` (Blueprint-baked worker binding)
324///
325/// Resolved from `AgentDef.profile.worker_binding` (see `WorkerBinding`) and
326/// literally substituted for the old hardcoded `"mse-worker"` string — the
327/// Blueprint is the single source of truth for which Claude Code SubAgent
328/// definition the MainAI must dispatch. There is deliberately **no fallback**
329/// to another `subagent_type` here: if the named SubAgent definition is not
330/// registered, the MainAI is instructed to fail the SpawnAck loud rather than
331/// silently substitute a different one.
332/// `base_url` is the server's public HTTP root (e.g.
333/// `"http://127.0.0.1:7777"`). When `Some`, it is rendered verbatim into
334/// the SubAgent prompt block so the operator can copy the frame
335/// straight through without a `mse_doctor` lookup (issue #8). When
336/// `None`, a fallback placeholder points the reader at `mse_doctor` —
337/// no fake port number appears in the directive.
338pub(super) fn default_spawn_directive(
339    agent: &str,
340    task_id: &str,
341    subagent_type: &str,
342    project_name_alias: Option<&str>,
343    data_sink_endpoint: Option<&str>,
344    base_url: Option<&str>,
345) -> String {
346    // Expanded only when Blueprint.metadata.project_name_alias is Some.
347    // Presents a discipline reminder to the MainAI plus the literal line the
348    // SubAgent prompt should inject.
349    let project_alias_line = match project_name_alias {
350        Some(a) => format!("project_name_alias: {a}\n"),
351        None => String::new(),
352    };
353    // Endpoint hint for the Data path (Big Response routing). Only when
354    // Some, inject a convention line telling the MainAgent to pass the Big
355    // EMIT POST target URL into the SubAgent prompt or environment when it
356    // kicks a SubAgent. Audience: MainAgent (the SubAgent-launcher side).
357    // A single authenticated emit endpoint: the token can be passed as
358    // Bearer or `?token=`; both consume the same CapToken material.
359    let data_endpoint_block = match data_sink_endpoint {
360        Some(base) => format!(
361            "\n\
362             [Data path endpoint — MainAgent reminder]\n\
363             When you kick a SubAgent, inject the following two lines into\n\
364             its prompt / environment so Big Response payloads (4k+ tokens,\n\
365             files, intermediate artifacts) flow directly to the Store owner,\n\
366             bypassing the MainAgent (context stays small; only the out_id\n\
367             ref is passed around).\n  \
368             DATA_EMIT: {base}/v1/data/emit  (POST, auth = Bearer worker_handle or ?token=)\n  \
369             DATA_GET:  {base}/v1/data/<out_id|out_name>  (the next SubAgent fetches from $IN_REFS)\n\
370             When a SubAgent produces a Big Response, POST it to DATA_EMIT\n\
371             and return only the one-line out_id ref (do not mix the body\n\
372             in; the MainAgent must not answer directly).\n\
373             \n"
374        ),
375        None => String::new(),
376    };
377    let main_ai_reminder = match project_name_alias {
378        Some(a) => format!(
379            "\n\
380             [LDS Session Alias Reminder — MainAI mandatory]\n\
381             Before kicking the SubAgent below, call:\n  \
382             mcp__lds__session_create(root=<working_dir>, alias=\"{a}\")\n\
383             (= establish a single task-level lds session; reuse on repeated dispatch).\n\
384             Then add this literal line to the SubAgent prompt body below:\n  \
385             LDS Session Alias: {a}\n\
386             The SubAgent will call mcp__lds__session_start(alias=\"{a}\") on init,\n\
387             keeping worktree ownership unified across dispatches.\n\
388             (Full discipline rationale is inlined above; reach is via this directive itself,\n\
389              not via any external doc path. The 2 steps above are the complete contract.)\n\
390             \n"
391        ),
392        None => String::new(),
393    };
394    // Issue #8: render the actual server bind literally when it was
395    // sourced at boot; fall back to a pointer at `mse_doctor` rather
396    // than a fake port number.
397    let base_url_line = match base_url {
398        Some(u) => u.to_string(),
399        None => "<your server's actual bind — check with mse_doctor>".to_string(),
400    };
401    format!(
402        "[agent_primitive dispatch=@{agent}]\n\
403         worker endpoint:\n  \
404         GET  <base_url>/v1/worker/prompt?task_id={task_id}\n  \
405         POST <base_url>/v1/worker/submit\n\
406         auth: Bearer <worker_handle from THIS Spawn payload (= short `wh-XXXXXXXX` form)>\n\
407         task_id: {task_id}\n\
408         agent_id: {agent}\n\
409         {project_alias_line}\
410         {data_endpoint_block}\
411         {main_ai_reminder}\
412         Kick a SubAgent via Agent tool with subagent_type=\"{subagent_type}\" (= project-local \
413         `.claude/agents/{subagent_type}.md`, this agent's Blueprint-declared worker binding). \
414         The prompt you pass to it MUST be EXACTLY these 4 lines (no preamble, no extra text):\n\
415         \n  \
416         agent_id: {agent}\n  \
417         worker_handle: <THIS Spawn payload's `worker_handle` field (short string `wh-XXXXXXXX`)>\n  \
418         base_url: {base_url_line}\n  \
419         task_id: {task_id}\n\
420         \n\
421         The SubAgent self-fetches system + prompt via GET (Bearer = handle), \
422         executes as agent @{agent}, POSTs raw body to /v1/worker/submit (Bearer = handle, \
423         server resolves task_id from handle), and replies `OUTPUT` 1 word. You then forward \
424         SpawnAck {{req_id, value:{{}}, ok:true}} through your operator client — MCP path: \
425         mse_ack(sid, req_id, kind=\"spawn_ack\", ok=true) (= empty value because canonical \
426         body lives in output_tail via the POST). \
427         Do NOT fetch /v1/worker/prompt yourself. Do NOT wrap, summarize, or field-select \
428         the SubAgent reply. Observation / debug is a separate channel (= agent-inspect MCP / \
429         GET /v1/tasks/{{id}}), do NOT mix it into the forward path. \
430         If the SubAgent type is not registered, FAIL LOUD: reply SpawnAck ok=false with an \
431         error explaining the missing `.claude/agents/{subagent_type}.md` — do NOT fall back \
432         to another subagent_type."
433    )
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn directive_omits_project_name_alias_when_none() {
442        let d = default_spawn_directive("impl-lead", "task-x", "mse-worker-coder", None, None, None);
443        assert!(!d.contains("project_name_alias:"));
444        assert!(!d.contains("LDS Session Alias"));
445        assert!(!d.contains("session_create"));
446    }
447
448    #[test]
449    fn directive_emits_project_name_alias_when_some() {
450        let d = default_spawn_directive(
451            "impl-lead",
452            "task-x",
453            "mse-worker-coder",
454            Some("mse-task-7785"),
455            None,
456            None,
457        );
458        // Header line (expanded verbatim from the value).
459        assert!(
460            d.contains("project_name_alias: mse-task-7785"),
461            "directive missing project_name_alias header: {d}"
462        );
463        // MainAI mandatory reminder (= session_create + SubAgent prompt inject)
464        assert!(
465            d.contains("mcp__lds__session_create(root=<working_dir>, alias=\"mse-task-7785\")"),
466            "directive missing session_create reminder: {d}"
467        );
468        assert!(
469            d.contains("LDS Session Alias: mse-task-7785"),
470            "directive missing SubAgent prompt inject line: {d}"
471        );
472        // Reach discipline: the rationale is inlined into the directive (no external doc path reference).
473        assert!(
474            d.contains("inlined above") || d.contains("complete contract"),
475            "directive should inline rationale rather than point at external doc: {d}"
476        );
477        // The SoT is not pointed at an AI personal memory file (which is
478        // outside the MainAI's reach) — reach-axis consistency. Path
479        // references coming from the subagent registration convention (for
480        // example `agents/mse-worker.md`) are a separate case and are
481        // allowed. The pattern is assembled by string concat so that no
482        // gitignored dir literal remains in the source and the
483        // internal-doc-leak / secret-pre-commit-checker mechanical pattern
484        // match is avoided.
485        let forbidden_doc_ref = format!(".{}/CLAUDE.md", "claude");
486        assert!(
487            !d.contains(&forbidden_doc_ref),
488            "directive must not reference {forbidden_doc_ref} (out of MainAI scope): {d}"
489        );
490    }
491
492    #[test]
493    fn directive_omits_data_endpoint_when_none() {
494        let d = default_spawn_directive("impl-lead", "task-x", "mse-worker-coder", None, None, None);
495        assert!(!d.contains("[Data path endpoint"));
496        assert!(!d.contains("DATA_EMIT"));
497        assert!(!d.contains("DATA_GET"));
498    }
499
500    #[test]
501    fn directive_emits_data_endpoint_when_some() {
502        let base = "http://127.0.0.1:7785";
503        let d = default_spawn_directive(
504            "impl-lead",
505            "task-x",
506            "mse-worker-coder",
507            None,
508            Some(base),
509            None,
510        );
511        assert!(
512            d.contains("[Data path endpoint"),
513            "directive missing data endpoint block header: {d}"
514        );
515        assert!(
516            d.contains(&format!("DATA_EMIT: {base}/v1/data/emit")),
517            "directive missing single-mouth emit line: {d}"
518        );
519        assert!(
520            d.contains("Bearer worker_handle or ?token="),
521            "directive missing auth transport hint: {d}"
522        );
523        assert!(
524            d.contains(&format!("DATA_GET:  {base}/v1/data/<out_id|out_name>")),
525            "directive missing GET line: {d}"
526        );
527        assert!(
528            !d.contains("emit-auth"),
529            "old split endpoint must not leak into directive: {d}"
530        );
531        assert!(
532            d.contains("bypassing the MainAgent") && d.contains("out_id ref"),
533            "directive should carry the ownership + bypass reasoning: {d}"
534        );
535    }
536
537    #[test]
538    fn directive_carries_declared_subagent_type_and_has_no_fallback() {
539        let d = default_spawn_directive("impl-lead", "task-x", "mse-worker-coder", None, None, None);
540        assert!(
541            d.contains("subagent_type=\"mse-worker-coder\""),
542            "directive must carry the Blueprint-declared subagent_type literally: {d}"
543        );
544        assert!(
545            d.contains(".claude/agents/mse-worker-coder.md"),
546            "directive must reference the declared subagent's own .md path: {d}"
547        );
548        // The old hardcoded default and its silent-fallback text must be gone.
549        assert!(
550            !d.contains("general-purpose"),
551            "directive must not fall back to subagent_type=\"general-purpose\": {d}"
552        );
553        assert!(
554            !d.contains("mse-worker\""),
555            "directive must not carry the old hardcoded \"mse-worker\" literal: {d}"
556        );
557        assert!(
558            d.contains("FAIL LOUD"),
559            "directive must instruct the MainAI to fail loud instead of falling back: {d}"
560        );
561    }
562
563    // ─── Issue #8: base_url rendering + fallback framing ─────────────────
564
565    /// Layer 1: when `base_url` is `Some`, it must land verbatim in the
566    /// SubAgent-prompt block, so the operator can copy the frame
567    /// through without a `mse_doctor` lookup.
568    #[test]
569    fn directive_renders_actual_base_url_when_some() {
570        let d = default_spawn_directive(
571            "impl-lead",
572            "task-x",
573            "mse-worker-coder",
574            None,
575            None,
576            Some("http://127.0.0.1:8888"),
577        );
578        assert!(
579            d.contains("base_url: http://127.0.0.1:8888"),
580            "directive must render the actual bind literally: {d}"
581        );
582        assert!(
583            !d.contains("mse_doctor"),
584            "no mse_doctor detour when bind is known: {d}"
585        );
586    }
587
588    /// Layer 3: when `base_url` is `None` (unit tests, mock harnesses,
589    /// pre-serve rendering) the fallback line must point the reader at
590    /// `mse_doctor` — never a fake port number.
591    #[test]
592    fn directive_falls_back_to_mse_doctor_pointer_when_none() {
593        let d =
594            default_spawn_directive("impl-lead", "task-x", "mse-worker-coder", None, None, None);
595        assert!(
596            d.contains("check with mse_doctor"),
597            "fallback must point at mse_doctor: {d}"
598        );
599    }
600
601    /// Regression guard: the historical `7786` example port (the whole
602    /// origin of issue #8) must not survive in the rendered directive
603    /// under any input combination.
604    #[test]
605    fn directive_never_contains_stale_example_port_7786() {
606        for base in [
607            None,
608            Some("http://127.0.0.1:7777"),
609            Some("http://192.0.2.1:9000"),
610        ] {
611            let d = default_spawn_directive(
612                "impl-lead",
613                "task-x",
614                "mse-worker-coder",
615                Some("mse-task-alias"),
616                Some("http://127.0.0.1:7785"),
617                base,
618            );
619            assert!(
620                !d.contains("7786"),
621                "stale example port 7786 leaked: base={base:?}, d={d}"
622            );
623        }
624    }
625
626    // ─── Issue #7: spawn_halt handling in Operator::execute ──────────────
627
628    fn test_ctx(task_id: &str) -> mlua_swarm::Ctx {
629        mlua_swarm::Ctx::new(mlua_swarm::TaskId(task_id.into()), 1, "a")
630    }
631
632    fn test_worker_binding() -> mlua_swarm::WorkerBinding {
633        mlua_swarm::WorkerBinding {
634            variant: "test-variant".into(),
635            tools: vec![],
636        }
637    }
638
639    fn test_cap_token() -> mlua_swarm::CapToken {
640        mlua_swarm::CapToken {
641            agent_id: "a".into(),
642            role: mlua_swarm::Role::Worker,
643            scopes: vec!["*".into()],
644            issued_at: 0,
645            expire_at: u64::MAX / 2,
646            max_uses: None,
647            nonce: "test-nonce".into(),
648            sig_hex: "".into(),
649        }
650    }
651
652    /// A `PendingReply::SpawnHalt` reply must translate into a
653    /// `Ok(WorkerResult { ok: true, value: <halt marker> })` — a normal
654    /// termination, not a `WorkerError::Failed` (fail-loud). This is
655    /// the whole point of the new verb: distinguishing a controlled
656    /// halt from a real worker error at the log / retry-signal level.
657    #[tokio::test]
658    async fn spawn_halt_reply_lands_as_ok_worker_result_with_marker() {
659        use mlua_swarm::Operator;
660        use tokio::sync::mpsc;
661
662        let (tx, mut rx) = mpsc::unbounded_channel();
663        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
664            "sid-halt".into(),
665            tx,
666            None,
667        ));
668
669        // Kick execute() in a background task so we can grab the
670        // req_id the server assigns and inject a matching SpawnHalt.
671        let session_bg = session.clone();
672        let handle = tokio::spawn(async move {
673            session_bg
674                .execute(
675                    &test_ctx("task-halt"),
676                    None,
677                    "".into(),
678                    Some(test_worker_binding()),
679                    test_cap_token(),
680                )
681                .await
682        });
683
684        let sent = rx.recv().await.expect("Spawn sent");
685        let req_id = match sent {
686            ServerMsg::Spawn { req_id, .. } => req_id,
687            other => panic!("expected Spawn, got {other:?}"),
688        };
689
690        session
691            .resolve_pending(
692                &req_id,
693                PendingReply::SpawnHalt {
694                    value: serde_json::json!({"partial": "abc"}),
695                    reason: Some("shape verified".into()),
696                },
697            )
698            .await;
699
700        let result = handle.await.expect("join").expect("execute Ok");
701        assert!(
702            result.ok,
703            "spawn_halt must land as ok=true (normal termination), got: {result:?}"
704        );
705        assert_eq!(result.value["halted"], true);
706        assert_eq!(result.value["reason"], "shape verified");
707        assert_eq!(result.value["value"], serde_json::json!({"partial": "abc"}));
708    }
709
710    /// `spawn_ack { ok: false, error: Some(_) }` must retain its
711    /// current fail-loud behaviour (backward compat guard).
712    #[tokio::test]
713    async fn spawn_ack_with_error_still_lands_as_worker_error() {
714        use mlua_swarm::{Operator, WorkerError};
715        use tokio::sync::mpsc;
716
717        let (tx, mut rx) = mpsc::unbounded_channel();
718        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
719            "sid-err".into(),
720            tx,
721            None,
722        ));
723
724        let session_bg = session.clone();
725        let handle = tokio::spawn(async move {
726            session_bg
727                .execute(
728                    &test_ctx("task-err"),
729                    None,
730                    "".into(),
731                    Some(test_worker_binding()),
732                    test_cap_token(),
733                )
734                .await
735        });
736
737        let sent = rx.recv().await.expect("Spawn sent");
738        let req_id = match sent {
739            ServerMsg::Spawn { req_id, .. } => req_id,
740            other => panic!("expected Spawn, got {other:?}"),
741        };
742
743        session
744            .resolve_pending(
745                &req_id,
746                PendingReply::SpawnAck {
747                    value: serde_json::json!({}),
748                    ok: false,
749                    error: Some("real crash".into()),
750                },
751            )
752            .await;
753
754        let err = handle.await.expect("join").expect_err("must be error");
755        assert!(matches!(err, WorkerError::Failed(msg) if msg.contains("real crash")));
756    }
757}