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::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
29/// 1 sid = 1 session. Looked up by sid in the `operator_sessions` store on reconnect.
30pub struct WSOperatorSession {
31    sid: SessionId,
32    /// The current mpsc sender on the write path. `None` on disconnect;
33    /// swapped to `Some(new_tx)` on reconnect.
34    tx: Mutex<Option<mpsc::UnboundedSender<ServerMsg>>>,
35    /// `req_id` → pending oneshot. Resolved when `answer` / `hook_ack` /
36    /// `spawn_ack` arrives.
37    pending: Mutex<HashMap<String, oneshot::Sender<PendingReply>>>,
38    /// Public HTTP base URL the server is reachable at (from
39    /// `AppState.base_url`, sourced from the binary at boot time).
40    /// Rendered literally into the Spawn `directive`'s `base_url` line
41    /// when `Some`; `None` falls back to a `mse_doctor`-pointer
42    /// placeholder (issue #8).
43    base_url: Option<std::sync::Arc<str>>,
44}
45
46impl WSOperatorSession {
47    /// `login.rs::handle_operator_socket` is the sole constructor call site.
48    /// Auth (Bearer token match) is checked there against `OperatorSessionEntry.token`
49    /// *before* upgrade — this struct no longer carries its own auth_token copy.
50    ///
51    /// `base_url` is the server's public HTTP root (e.g.
52    /// `"http://127.0.0.1:7777"`), threaded from `AppState.base_url`.
53    /// When `Some`, it is rendered literally into Spawn directives
54    /// (issue #8); `None` falls back to a `mse_doctor`-pointer
55    /// placeholder.
56    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    /// Swaps in a new tx on reconnect. Expected to be called only from the handler side.
70    pub(super) async fn replace_tx(&self, new_tx: mpsc::UnboundedSender<ServerMsg>) {
71        *self.tx.lock().await = Some(new_tx);
72    }
73
74    /// Clears tx to `None` on disconnect. Expected to be called only from the handler side.
75    pub(crate) async fn clear_tx(&self) {
76        *self.tx.lock().await = None;
77    }
78
79    /// Resolves the pending oneshot when a `ClientMsg` arrives on the handler's
80    /// read task. If `req_id` is not registered, no-op (= silently drops unknown acks).
81    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    /// Inserts an entry into pending, sends S→C, and waits for the reply. No
88    /// timeout in v1.5 (= an ask during a disconnect immediately returns `Err`
89    /// on send failure; reconnect-wait behavior is v2).
90    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        // Fetch `tx` and send. When None, we are disconnected — fail fast.
95        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    /// Fire-and-forget send for `after` (= no reply expected).
114    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        // `after` is fire-and-forget — swallow send failures.
189        let _ = self.send_oneway(msg).await;
190        Ok(())
191    }
192}
193
194#[async_trait]
195impl Operator for WSOperatorSession {
196    /// Thin control channel impl (the Spawn thin-control axis): `system` / `prompt`
197    /// have already been baked into engine state on the server side
198    /// (= `bake_worker_system_prompt` in `OperatorSpawner.spawn` + the existing
199    /// `fetch_prompt` path). This impl encodes `worker_token` and hands it to
200    /// the MainAI in a single Spawn message; the SubAgent then hits
201    /// `/v1/worker/prompt` + `/v1/worker/result` itself over HTTP. `system` is
202    /// intentionally **not used here** (heavy payloads are not carried on WS —
203    /// thin-path discipline); `prompt` (issue #18) is used only to recover a
204    /// `Value` for the `Spawn.directive` reminder line (see
205    /// `default_spawn_directive_with_task_directive`) — the SubAgent still
206    /// self-fetches the full prompt over HTTP, unchanged.
207    ///
208    /// The SubAgent's result post (= HTTP POST `/v1/worker/result`) appends
209    /// `Final` to `output_tail`; when the MainAI returns `SpawnAck`, this
210    /// `execute` returns `WorkerResult` and control returns to the dispatch path.
211    ///
212    /// `worker` is required (see `requires_worker_binding`) — the compile-time
213    /// gate in `OperatorSpawnerFactory::build` is the primary defense, but a
214    /// `None` can still reach here on paths that bypass compilation (e.g. an
215    /// operator-sid-pin path). This runtime check is the defensive second
216    /// layer: fail the task loud rather than silently degrade to the old
217    /// hardcoded `"mse-worker"` literal.
218    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        // issue #13 run_id propagation: `EngineDispatcher::with_run` (when
246        // the launch carries a `RunContext`) inserts this into
247        // `Ctx.meta.runtime["run_id"]`; `None` on launches with no run
248        // tracing (see `Engine::dispatch_attempt_with`'s `run_id` param).
249        let run_id = ctx.meta.runtime.get("run_id").and_then(|v| v.as_str());
250        // GH #20 Contract C: `project_name_alias` / `project_root` /
251        // `work_dir` (previously read individually here) now come off one
252        // materialized `AgentContextView` — reads back the view
253        // `AgentContextMiddleware` stashed into
254        // `ctx.meta.runtime[AGENT_CONTEXT_KEY]`, falling back to a
255        // field-by-field pull off `ctx.meta.runtime` when that middleware
256        // was never layered (backward compat). See the module doc on
257        // `mlua_swarm::core::agent_context` for the full narrative.
258        let view = AgentContextView::materialized_or_from_ctx(ctx);
259        // issue #18: `prompt` is `TaskSpec.initial_directive`, threaded as
260        // `Value` end-to-end through `EngineState.prompts` /
261        // `Engine::fetch_prompt`. The WS Spawn frame text render is the
262        // sole String boundary on this axis — no re-parse round trip,
263        // and Object / Array / Number seeds keep their structural shape
264        // all the way to the render call.
265        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        // GH #27 (follow-up to #23): the ProjectionPlacement resolver
276        // `AgentContextMiddleware` resolved (via `Engine::projection_placement_for`,
277        // which this WS session has no direct handle to call itself) and
278        // stashed into `ctx.meta.runtime[PROJECTION_PLACEMENT_KEY]` — falls
279        // back to the byte-compat default when absent or undeserializable
280        // (middleware never layered onto this spawner stack, e.g. tests
281        // driving `execute` directly against a bare `Ctx`).
282        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        // issue #21/ST2 in-flight projection hook: materializes `view`
289        // (already `apply_policy`-filtered — see `AgentContextMiddleware`)
290        // to file and appends a `ctx_projection:` pointer line. See
291        // `append_projection_pointer`'s doc for the fallback contract.
292        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            // `spawn_halt` (issue #7): controlled halt. Return
320            // `Ok(WorkerResult { ok: true, value: halt_marker })` so the
321            // step lands as a normal termination rather than a
322            // `WorkerError::Failed` — log stays `info`, downstream retry
323            // logic doesn't fire. The halt marker carries the caller's
324            // partial value and reason string in a fixed shape.
325            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/// Literal instruction text for the MainAI (= WS Client = Operator role). Fix
349/// for observation #7.
350///
351/// Minimal hand-off form parallel to /orch (agent_primitive): sends an
352/// `[agent_primitive dispatch=@<agent>]` marker + worker endpoint + auth +
353/// task_id in the payload; the MainAI **kicks a SubAgent by specifying AgentId +
354/// Token** and **forwards the return string verbatim into `SpawnAck.value`**.
355///
356/// The detailed instructions for the SubAgent are consolidated into the
357/// agent.md `system` (= the body fetched by `GET /v1/worker/prompt`); the
358/// directive is narrowed to the minimum routing information.
359///
360/// # `project_name_alias` / `project_root` / `work_dir` / `task_metadata`
361/// (GH #20 Contract C — `AgentContextView.to_directive_header`)
362///
363/// These task-level context header lines are no longer read individually
364/// here — they come off one materialized `view: &AgentContextView`
365/// (see `mlua_swarm::core::agent_context` for the full Contract C
366/// narrative) via [`AgentContextView::to_directive_header`], rendered
367/// verbatim at the top of the "worker endpoint" block below. Format is
368/// byte-identical to the pre-#20 individual splices
369/// (`project_name_alias: {a}` / `project_root: {p}` / `work_dir: {w}`,
370/// each independently absent-or-present, no empty-string placeholder) —
371/// the additive change is the new `task_metadata: {compact-json}` line
372/// (closes the F2 gap tracked in the `operator-execution-model` guide)
373/// plus one line per `view.extra` entry.
374///
375/// `project_name_alias` is ALSO used below (via `view.project_name_alias`)
376/// to expand the "LDS Session Alias" mandatory reminder block for the
377/// MainAI — the engine itself performs no other action on the alias; the
378/// expansion here is what the MainAI actually reads.
379///
380/// # `subagent_type` (Blueprint-baked worker binding)
381///
382/// Resolved from `AgentDef.profile.worker_binding` (see `WorkerBinding`) and
383/// literally substituted for the old hardcoded `"mse-worker"` string — the
384/// Blueprint is the single source of truth for which Claude Code SubAgent
385/// definition the MainAI must dispatch. There is deliberately **no fallback**
386/// to another `subagent_type` here: if the named SubAgent definition is not
387/// registered, the MainAI is instructed to fail the SpawnAck loud rather than
388/// silently substitute a different one.
389/// `base_url` is the server's public HTTP root (e.g.
390/// `"http://127.0.0.1:7777"`). When `Some`, it is rendered verbatim into
391/// the SubAgent prompt block so the operator can copy the frame
392/// straight through without a `mse_doctor` lookup (issue #8). When
393/// `None`, a fallback placeholder points the reader at `mse_doctor` —
394/// no fake port number appears in the directive.
395///
396/// `run_id` (issue #13 ID-hierarchy persistence) is `Some` whenever this
397/// dispatch's `Ctx.meta.runtime["run_id"]` is populated (see
398/// `Engine::dispatch_attempt_with`), and is rendered into the observation
399/// route hint below (`GET /v1/runs/{run_id}`) so a MainAI reading the
400/// directive can drill into that specific kick's `RunRecord.step_entries`
401/// trace. `None` falls back to a generic `<run_id>` placeholder. Kept as
402/// its own parameter (not read off `view`) — the directive's observation
403/// route hint is a separate rendering concern from the task-level context
404/// header.
405#[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    // GH #20: task-level context header lines (project_name_alias /
416    // project_root / work_dir / task_metadata / extra), rendered by the
417    // materialized view itself. See the doc above.
418    let context_header = view.to_directive_header();
419    // Endpoint hint for the Data path (Big Response routing). Only when
420    // Some, inject a convention line telling the MainAgent to pass the Big
421    // EMIT POST target URL into the SubAgent prompt or environment when it
422    // kicks a SubAgent. Audience: MainAgent (the SubAgent-launcher side).
423    // A single authenticated emit endpoint: the token can be passed as
424    // Bearer or `?token=`; both consume the same CapToken material.
425    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    // Issue #8: render the actual server bind literally when it was
461    // sourced at boot; fall back to a pointer at `mse_doctor` rather
462    // than a fake port number.
463    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    // issue #13: the real drill-down route is `GET /v1/runs/{run_id}` (a
468    // single `RunRecord`, `step_entries` trace included) — `GET
469    // /v1/tasks/{id}` does exist but returns the coarser `TaskRecord` +
470    // every `RunRecord` kicked from it, not this specific kick.
471    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/// Wraps [`default_spawn_directive`]'s routing/reminder text as the WS
511/// `Spawn.directive` `Value` (issue #18), additionally splicing in a
512/// `task_directive` line built from `TaskSpec.initial_directive` when the
513/// task was seeded with one.
514///
515/// This is the sole place the render from `Value` (`task_directive`) down
516/// to `String` literal happens for the WS Spawn path — the coercion that
517/// used to sit in `EngineDispatcher::dispatch` moved here. `task_directive
518/// == Value::Null` (no seed, or the caller could not recover one) omits
519/// the line entirely, leaving the output byte-identical to
520/// [`default_spawn_directive`]'s own text — this preserves every existing
521/// [`default_spawn_directive`] test unchanged, since that function's
522/// signature and body are untouched by issue #18.
523#[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    // Strings pass through verbatim; anything else (Object / Array /
544    // Number / Bool) is serde-stringified — the same coercion pattern
545    // `EngineDispatcher::dispatch` used to apply eagerly, now applied
546    // lazily at this render boundary only.
547    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
555/// issue #21/ST2 in-flight projection hook: projects `view` (the
556/// spawn-time, already `apply_policy`-filtered [`AgentContextView`] — see
557/// `AgentContextMiddleware`'s module doc) to file via a fresh
558/// [`FileProjectionAdapter`] rooted at the materialize root
559/// `placement.resolve_root(view)` resolves (GH #27, follow-up to #23 —
560/// see `mlua_swarm::core::projection_placement`'s module doc for the "3
561/// path" convergence this closes: this hook used to check `view.work_dir`
562/// ONLY, with no fallback to `view.project_root`, an asymmetry against
563/// the other two call sites the shared resolver now removes), and appends
564/// a single `ctx_projection: {json}\n` line to `directive` — a
565/// `{key}: {value}\n` splice matching [`AgentContextView::to_directive_header`]'s
566/// own line convention (e.g. its `task_metadata: {compact-json}\n` line),
567/// never the projected value itself (pointer-only supply; see
568/// `mlua_swarm::core::projection`'s module doc for why).
569///
570/// An unresolved root, `view` failing to serialize, or the materialize
571/// write itself failing, all fall back to `directive` unchanged (no
572/// pointer line) rather than failing the spawn — subtask-2's Invariants
573/// require this hook to never turn a would-have-succeeded spawn into a
574/// failure.
575///
576/// # projection-adapter ST5: `ctx_step_dir` line retired
577///
578/// This spawn-time `ctx_projection:` line supplies *this spawning agent's
579/// own* `AgentContextView` (kept, unchanged, above). A companion
580/// `ctx_step_dir:` line — pointing the worker at
581/// `<root>/workspace/tasks/<task_id>/ctx/` plus the `mse_ctx_get` MCP tool
582/// as a way to pull a *prior* step's OUTPUT out of it — existed from
583/// subtask-4 through ST4; ST5 retires both (see
584/// `mlua_swarm::core::agent_context`'s module doc): the Worker axis now
585/// gets prior steps' OUTPUT pointers automatically, pre-filtered through
586/// `ContextPolicy.steps`, on `AgentContextView.steps` (assembled by
587/// `crates/mlua-swarm-server/src/worker.rs`'s `GET /v1/worker/prompt`
588/// handler) — no separate directory hint or MCP tool call needed.
589fn 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    /// Test helper: builds an `AgentContextView` with only
647    /// `project_name_alias` / `project_root` / `work_dir` set (the three
648    /// fields `default_spawn_directive`'s retired individual params used
649    /// to carry) — everything else stays at `Default`. Mirrors the
650    /// pre-#20 call shape so the mechanical rewrite of every existing
651    /// test stays a 1:1 argument swap.
652    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        // Header line (expanded verbatim from the value).
693        assert!(
694            d.contains("project_name_alias: mse-task-7785"),
695            "directive missing project_name_alias header: {d}"
696        );
697        // MainAI mandatory reminder (= session_create + SubAgent prompt inject)
698        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        // Reach discipline: the rationale is inlined into the directive (no external doc path reference).
707        assert!(
708            d.contains("inlined above") || d.contains("complete contract"),
709            "directive should inline rationale rather than point at external doc: {d}"
710        );
711        // The SoT is not pointed at an AI personal memory file (which is
712        // outside the MainAI's reach) — reach-axis consistency. Path
713        // references coming from the subagent registration convention (for
714        // example `agents/mse-worker.md`) are a separate case and are
715        // allowed. The pattern is assembled by string concat so that no
716        // gitignored dir literal remains in the source and the
717        // internal-doc-leak / secret-pre-commit-checker mechanical pattern
718        // match is avoided.
719        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        // The old hardcoded default and its silent-fallback text must be gone.
800        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    // ─── Issue #8: base_url rendering + fallback framing ─────────────────
815
816    /// Layer 1: when `base_url` is `Some`, it must land verbatim in the
817    /// SubAgent-prompt block, so the operator can copy the frame
818    /// through without a `mse_doctor` lookup.
819    #[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    /// Layer 3: when `base_url` is `None` (unit tests, mock harnesses,
841    /// pre-serve rendering) the fallback line must point the reader at
842    /// `mse_doctor` — never a fake port number.
843    #[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    /// Regression guard: the historical `7786` example port (the whole
861    /// origin of issue #8) must not survive in the rendered directive
862    /// under any input combination.
863    #[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    // ─── Issue #13: run_id observation route (doc-drift fix) ─────────────
887
888    /// Regression guard: the stale `GET /v1/tasks/{id}` observation hint
889    /// (a route that never returns a single `RunRecord`) must be gone —
890    /// the directive must point at the real drill-down route instead.
891    #[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    /// When `run_id` is `Some`, it is rendered literally into the
909    /// observation route hint (`GET /v1/runs/<run_id>`).
910    #[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    /// `run_id: None` (no run tracing for this launch) falls back to a
928    /// generic placeholder route rather than a stale/incorrect one.
929    #[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    // ─── Issue #17: project_root / work_dir header lines ─────────────────
947
948    /// Both absent → neither header line appears (no empty-string
949    /// placeholder either).
950    #[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    /// Both present → both header lines render literally, alongside
966    /// `project_name_alias`'s existing splice.
967    #[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    /// Partial: `project_root` present, `work_dir` absent — each field is
989    /// independent, so only the present one renders.
990    #[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    // ─── GH #20: task_metadata header line (Contract C, closes the F2 gap) ─
1009
1010    /// `task_metadata` renders as a new `task_metadata: {compact-json}`
1011    /// line — the F2 gap the `operator-execution-model` guide tracked
1012    /// (`task_metadata`'s inner keys were never spliced into the
1013    /// directive before GH #20).
1014    #[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        // Additive-only: the pre-existing project_root line still renders.
1034        assert!(d.contains("project_root: /repo"));
1035    }
1036
1037    /// `task_metadata: None` (absent) omits the line entirely — no
1038    /// empty-string placeholder, matching every other header line's
1039    /// absent-field contract.
1040    #[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    // ─── Issue #7: spawn_halt handling in Operator::execute ──────────────
1055
1056    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    /// A `PendingReply::SpawnHalt` reply must translate into a
1081    /// `Ok(WorkerResult { ok: true, value: <halt marker> })` — a normal
1082    /// termination, not a `WorkerError::Failed` (fail-loud). This is
1083    /// the whole point of the new verb: distinguishing a controlled
1084    /// halt from a real worker error at the log / retry-signal level.
1085    #[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        // Kick execute() in a background task so we can grab the
1098        // req_id the server assigns and inject a matching SpawnHalt.
1099        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    /// `spawn_ack { ok: false, error: Some(_) }` must retain its
1139    /// current fail-loud behaviour (backward compat guard).
1140    #[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    // ─── Issue #17: end-to-end `execute()` splice (ctx.meta.runtime → Spawn.directive) ───
1187
1188    /// `Ctx.meta.runtime` carrying both `project_root` and `work_dir`
1189    /// (the `TaskInputMiddleware` injection shape) must land in the
1190    /// `ServerMsg::Spawn.directive` actually sent over the wire — not
1191    /// just in the pure `default_spawn_directive` helper.
1192    #[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                // issue #18: `Spawn.directive` is now `Value`; extract the
1233                // `String` it wraps (always a `Value::String` on this
1234                // path — see `default_spawn_directive_with_task_directive`).
1235                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    /// Partial: only `project_root` present in `ctx.meta.runtime` (no
1263    /// `TaskInputMiddleware`-populated `work_dir`) — the splice is
1264    /// per-field independent, matching `TaskInputMiddleware`'s own
1265    /// per-field-optional contract.
1266    #[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    /// Neither present in `ctx.meta.runtime` (no `TaskInputMiddleware`
1327    /// layered for this launch) — the directive carries neither header
1328    /// line, matching pre-issue-#17 behavior exactly.
1329    #[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    /// GH #20 / F2 gap: `task_metadata` in `ctx.meta.runtime` (the
1383    /// `TaskInputMiddleware` injection shape) now reaches the
1384    /// `ServerMsg::Spawn.directive` actually sent over the wire, via
1385    /// `AgentContextView::materialized_or_from_ctx` falling back to
1386    /// `from_ctx` when `AgentContextMiddleware` was not layered.
1387    #[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    // ─── Issue #18: `Value` pass-through render boundary
1447    //     (`default_spawn_directive_with_task_directive`) ───
1448
1449    /// A `String` seed splices in verbatim, unquoted (matching
1450    /// `Value::String(s) => s.clone()` — no JSON-quoting artifact).
1451    #[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    /// An Object seed renders as its JSON literal (issue #18 Invariant 3 —
1471    /// same shape `Engine::start_task` / `Engine::dispatch_attempt_with`
1472    /// produce for the Worker HTTP path via `render_directive_to_string`).
1473    #[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    /// `Value::Null` (no seed recovered) omits the line entirely — the
1493    /// output is byte-identical to `default_spawn_directive`'s own text,
1494    /// preserving every pre-issue-#18 caller unchanged.
1495    #[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    /// End-to-end via `execute()`: an Object-shaped `Step.in` seed, once
1524    /// rendered to a JSON-literal `String` by the engine (the Worker HTTP
1525    /// path's `render_directive_to_string`), reaches `ServerMsg::Spawn`
1526    /// with the same JSON literal spliced into `directive` — the WS
1527    /// render layer is the sole `Value → String` coercion point on this
1528    /// path (issue #18).
1529    #[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        // Issue #18: `Value` flows end-to-end from `Step.in` through the
1543        // engine, so the Object seed reaches `execute()` as `Value` — no
1544        // stringification upstream. Only the WS Spawn frame render
1545        // performs the `Value → String` coercion.
1546        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    // ─── issue #21/ST2: in-flight projection hook (`append_projection_pointer`) ───
1590
1591    /// `view.work_dir` present → the spawn directive carries a
1592    /// `ctx_projection:` pointer line, and the pointed-at file actually
1593    /// exists on disk (subtask-2 Tests #3).
1594    #[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                // ST5 (`projection-adapter`) removal confirmation: the
1636                // pre-ST5 `ctx_step_dir:` companion line (pointing a
1637                // worker at the raw materialize directory + the retired
1638                // `mse_ctx_get` MCP tool) must never reappear — the
1639                // Worker axis now gets prior steps' OUTPUT pointers
1640                // automatically via `context.steps`.
1641                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    /// `view.work_dir` absent → the spawn directive carries no
1670    /// `ctx_projection:` line, and the spawn still succeeds (non-fatal
1671    /// fallback, subtask-2 Tests #4 + Invariant "must never turn a
1672    /// would-have-succeeded spawn into a failure").
1673    #[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    // ──────────────────────────────────────────────────────────────
1730    // GH #27 (follow-up to #23): ProjectionPlacement resolver wiring
1731    // ──────────────────────────────────────────────────────────────
1732
1733    /// `view.work_dir` ABSENT but `view.project_root` present, with the
1734    /// byte-compat default `ProjectionPlacement` (`root_preference =
1735    /// WorkDir`, falling back to `project_root`) — the asymmetry fix: a
1736    /// pre-GH-#27 build would have skipped the pointer entirely here
1737    /// (`view.work_dir` ONLY, no fallback); this build now falls back the
1738    /// SAME way the submit-time sink and server read-back always did.
1739    #[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    /// A `ProjectionPlacement` stashed into
1805    /// `ctx.meta.runtime[PROJECTION_PLACEMENT_KEY]` (the same channel
1806    /// `AgentContextMiddleware` populates at spawn time) with
1807    /// `root_preference = ProjectRoot` and a custom `dir_template` changes
1808    /// BOTH which root is preferred (even though `work_dir` is ALSO
1809    /// present) AND the target directory layout the in-flight pointer
1810    /// materializes to.
1811    #[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}