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    /// Whether this session currently has a live WebSocket sender.
75    pub(super) async fn is_connected(&self) -> bool {
76        self.tx.lock().await.is_some()
77    }
78
79    /// Clear the sender only when it still belongs to the connection that is
80    /// shutting down. A stale socket can finish after a reconnect installed a
81    /// replacement sender; that stale cleanup must not disconnect the new
82    /// socket.
83    pub(super) async fn clear_tx_if(&self, expected: &mpsc::UnboundedSender<ServerMsg>) {
84        let mut current = self.tx.lock().await;
85        if current
86            .as_ref()
87            .is_some_and(|sender| sender.same_channel(expected))
88        {
89            *current = None;
90        }
91    }
92
93    /// Clears tx to `None` on disconnect. Expected to be called only from the handler side.
94    pub(crate) async fn clear_tx(&self) {
95        *self.tx.lock().await = None;
96    }
97
98    /// Drains every in-flight `pending` entry, dropping its oneshot
99    /// `Sender`. Dropping the sender closes the reply channel, so the
100    /// matching `send_and_await` unparks from `orx.await` with an `Err`
101    /// immediately — an in-flight `ask` / `hook_before` / `spawn` fails
102    /// loud right away (for `Operator::execute`, a `WorkerError::Failed`)
103    /// instead of orphaning in `orx.await` until the run's sync timeout
104    /// (up to 300s) fires. `reason` is recorded for the log only — a
105    /// dropped `oneshot::Sender` can carry no payload, so the receiver
106    /// observes the generic "reply path closed" error.
107    ///
108    /// Called on **session teardown** (`DELETE /v1/operators/:sid` and
109    /// `/by-role`), where the session is being removed from
110    /// `operator_sessions` and can never be reconnected — so the
111    /// disconnect-survives-in-`pending` reconnect/resend contract (see the
112    /// module doc) does not apply. It is deliberately NOT called on a plain
113    /// WS disconnect, which keeps `pending` alive for a reconnecting client
114    /// to resend against.
115    pub(crate) async fn fail_pending(&self, reason: &str) {
116        let drained: Vec<(String, oneshot::Sender<PendingReply>)> =
117            self.pending.lock().await.drain().collect();
118        if !drained.is_empty() {
119            tracing::warn!(
120                sid = %self.sid,
121                count = drained.len(),
122                reason,
123                "ws operator session: failing in-flight pending replies"
124            );
125        }
126        // `drained` drops here: each `oneshot::Sender` closes its channel,
127        // unparking the corresponding `send_and_await` with an `Err`.
128    }
129
130    /// Resolves the pending oneshot when a `ClientMsg` arrives on the handler's
131    /// read task. If `req_id` is not registered, no-op (= silently drops unknown acks).
132    pub(super) async fn resolve_pending(&self, req_id: &str, reply: PendingReply) {
133        if let Some(otx) = self.pending.lock().await.remove(req_id) {
134            let _ = otx.send(reply);
135        }
136    }
137
138    /// Inserts an entry into pending, sends S→C, and waits for the reply. No
139    /// timeout in v1.5 (= an ask during a disconnect immediately returns `Err`
140    /// on send failure; reconnect-wait behavior is v2).
141    async fn send_and_await(&self, req_id: String, msg: ServerMsg) -> Result<PendingReply, String> {
142        let (otx, orx) = oneshot::channel::<PendingReply>();
143        self.pending.lock().await.insert(req_id.clone(), otx);
144
145        // Fetch `tx` and send. When None, we are disconnected — fail fast.
146        let send_result = {
147            let guard = self.tx.lock().await;
148            match guard.as_ref() {
149                Some(tx) => tx
150                    .send(msg)
151                    .map_err(|_| "ws send channel closed".to_string()),
152                None => Err("ws operator disconnected".to_string()),
153            }
154        };
155        if let Err(e) = send_result {
156            self.pending.lock().await.remove(&req_id);
157            return Err(e);
158        }
159
160        orx.await
161            .map_err(|_| "ws operator: oneshot cancelled (= reply path closed)".to_string())
162    }
163
164    /// Fire-and-forget send for `after` (= no reply expected).
165    async fn send_oneway(&self, msg: ServerMsg) -> Result<(), String> {
166        let guard = self.tx.lock().await;
167        match guard.as_ref() {
168            Some(tx) => tx
169                .send(msg)
170                .map_err(|_| "ws send channel closed".to_string()),
171            None => Err("ws operator disconnected".to_string()),
172        }
173    }
174}
175
176#[async_trait]
177impl SeniorBridge for WSOperatorSession {
178    async fn ask(&self, task_id: &StepId, question: Value) -> Result<Value, String> {
179        let req_id = format!("{}-ask-{}", self.sid, uuid::Uuid::new_v4());
180        let msg = ServerMsg::Ask {
181            req_id: req_id.clone(),
182            parent_req_id: current_parent_req_id(),
183            task_id: task_id.clone(),
184            question,
185        };
186        match self.send_and_await(req_id, msg).await? {
187            PendingReply::Answer(v) => Ok(v),
188            PendingReply::HookAck { .. } => {
189                Err("ws operator: unexpected hook_ack reply to ask".into())
190            }
191            PendingReply::SpawnAck { .. } => {
192                Err("ws operator: unexpected spawn_ack reply to ask".into())
193            }
194            PendingReply::SpawnHalt { .. } => {
195                Err("ws operator: unexpected spawn_halt reply to ask".into())
196            }
197        }
198    }
199}
200
201#[async_trait]
202impl SpawnHook for WSOperatorSession {
203    async fn before(&self, ctx: &Ctx) -> Result<(), String> {
204        let req_id = format!("{}-hb-{}", self.sid, uuid::Uuid::new_v4());
205        let msg = ServerMsg::HookBefore {
206            req_id: req_id.clone(),
207            parent_req_id: current_parent_req_id(),
208            task_id: ctx.task_id.clone(),
209            agent: ctx.agent.clone(),
210            attempt: ctx.attempt,
211        };
212        match self.send_and_await(req_id, msg).await? {
213            PendingReply::HookAck { ok: true, .. } => Ok(()),
214            PendingReply::HookAck { ok: false, reason } => {
215                Err(reason.unwrap_or_else(|| "ws operator: spawn rejected".into()))
216            }
217            PendingReply::Answer(_) => {
218                Err("ws operator: unexpected answer reply to hook_before".into())
219            }
220            PendingReply::SpawnAck { .. } => {
221                Err("ws operator: unexpected spawn_ack reply to hook_before".into())
222            }
223            PendingReply::SpawnHalt { .. } => {
224                Err("ws operator: unexpected spawn_halt reply to hook_before".into())
225            }
226        }
227    }
228
229    async fn after(&self, ctx: &Ctx, result: &Value) -> Result<(), String> {
230        let req_id = format!("{}-ha-{}", self.sid, uuid::Uuid::new_v4());
231        let msg = ServerMsg::HookAfter {
232            req_id,
233            parent_req_id: current_parent_req_id(),
234            task_id: ctx.task_id.clone(),
235            agent: ctx.agent.clone(),
236            attempt: ctx.attempt,
237            result: result.clone(),
238        };
239        // `after` is fire-and-forget — swallow send failures.
240        let _ = self.send_oneway(msg).await;
241        Ok(())
242    }
243}
244
245#[async_trait]
246impl Operator for WSOperatorSession {
247    /// Thin control channel impl (the Spawn thin-control axis): `system` / `prompt`
248    /// have already been baked into engine state on the server side
249    /// (= `bake_worker_system_prompt` in `OperatorSpawner.spawn` + the existing
250    /// `fetch_prompt` path). This impl encodes `worker_token` and hands it to
251    /// the MainAI in a single Spawn message; the SubAgent then hits
252    /// `/v1/worker/prompt` + `/v1/worker/result` itself over HTTP. `system` is
253    /// intentionally **not used here** (heavy payloads are not carried on WS —
254    /// thin-path discipline); `prompt` (issue #18) is used only to recover a
255    /// `Value` for the `Spawn.directive` reminder line (see
256    /// `default_spawn_directive_with_task_directive`) — the SubAgent still
257    /// self-fetches the full prompt over HTTP, unchanged.
258    ///
259    /// The SubAgent's result post (= HTTP POST `/v1/worker/result`) appends
260    /// `Final` to `output_tail`; when the MainAI returns `SpawnAck`, this
261    /// `execute` returns `WorkerResult` and control returns to the dispatch path.
262    ///
263    /// `worker` is required (see `requires_worker_binding`) — the compile-time
264    /// gate in `OperatorSpawnerFactory::build` is the primary defense, but a
265    /// `None` can still reach here on paths that bypass compilation (e.g. an
266    /// operator-sid-pin path). This runtime check is the defensive second
267    /// layer: fail the task loud rather than silently degrade to the old
268    /// hardcoded `"mse-worker"` literal.
269    async fn execute(
270        &self,
271        ctx: &Ctx,
272        _system: Option<String>,
273        prompt: Value,
274        worker: Option<WorkerBinding>,
275        worker_token: CapToken,
276    ) -> Result<WorkerResult, WorkerError> {
277        let Some(worker) = worker else {
278            return Err(WorkerError::Failed(format!(
279                "agent '{}' has no worker_binding; WS thin-path requires one \
280                 (Blueprint AgentDef.profile.worker_binding)",
281                ctx.agent
282            )));
283        };
284        let req_id = format!("{}-spawn-{}", self.sid, uuid::Uuid::new_v4());
285        let worker_handle = ctx
286            .meta
287            .runtime
288            .get("worker_handle")
289            .and_then(|v| v.as_str())
290            .map(|s| s.to_string());
291        let data_sink_endpoint = ctx
292            .meta
293            .runtime
294            .get("data_sink_endpoint")
295            .and_then(|v| v.as_str());
296        // issue #13 run_id propagation: `EngineDispatcher::with_run` (when
297        // the launch carries a `RunContext`) inserts this into
298        // `Ctx.meta.runtime["run_id"]`; `None` on launches with no run
299        // tracing (see `Engine::dispatch_attempt_with`'s `run_id` param).
300        let run_id = ctx.meta.runtime.get("run_id").and_then(|v| v.as_str());
301        // GH #20 Contract C: `project_name_alias` / `project_root` /
302        // `work_dir` (previously read individually here) now come off one
303        // materialized `AgentContextView` — reads back the view
304        // `AgentContextMiddleware` stashed into
305        // `ctx.meta.runtime[AGENT_CONTEXT_KEY]`, falling back to a
306        // field-by-field pull off `ctx.meta.runtime` when that middleware
307        // was never layered (backward compat). See the module doc on
308        // `mlua_swarm::core::agent_context` for the full narrative.
309        let view = AgentContextView::materialized_or_from_ctx(ctx);
310        // issue #18: `prompt` is `TaskSpec.initial_directive`, threaded as
311        // `Value` end-to-end through `EngineState.prompts` /
312        // `Engine::fetch_prompt`. The WS Spawn frame text render is the
313        // sole String boundary on this axis — no re-parse round trip,
314        // and Object / Array / Number seeds keep their structural shape
315        // all the way to the render call.
316        let directive = default_spawn_directive_with_task_directive(
317            &ctx.agent,
318            ctx.task_id.as_str(),
319            &worker.variant,
320            &view,
321            data_sink_endpoint,
322            self.base_url.as_deref(),
323            run_id,
324            &prompt,
325        );
326        // GH #27 (follow-up to #23): the ProjectionPlacement resolver
327        // `AgentContextMiddleware` resolved (via `Engine::projection_placement_for`,
328        // which this WS session has no direct handle to call itself) and
329        // stashed into `ctx.meta.runtime[PROJECTION_PLACEMENT_KEY]` — falls
330        // back to the byte-compat default when absent or undeserializable
331        // (middleware never layered onto this spawner stack, e.g. tests
332        // driving `execute` directly against a bare `Ctx`).
333        let projection_placement = ctx
334            .meta
335            .runtime
336            .get(PROJECTION_PLACEMENT_KEY)
337            .and_then(|v| serde_json::from_value::<ProjectionPlacement>(v.clone()).ok())
338            .unwrap_or_default();
339        // issue #21/ST2 in-flight projection hook: materializes `view`
340        // (already `apply_policy`-filtered — see `AgentContextMiddleware`)
341        // to file and appends a `ctx_projection:` pointer line. See
342        // `append_projection_pointer`'s doc for the fallback contract.
343        let directive = append_projection_pointer(
344            directive,
345            &ctx.task_id,
346            &view,
347            run_id,
348            &projection_placement,
349        );
350        let msg = ServerMsg::Spawn {
351            req_id: req_id.clone(),
352            parent_req_id: current_parent_req_id(),
353            task_id: ctx.task_id.clone(),
354            agent: ctx.agent.clone(),
355            attempt: ctx.attempt,
356            capability_token: worker_token.encode(),
357            worker_handle,
358            worker: Some(worker),
359            directive,
360        };
361        match self.send_and_await(req_id, msg).await {
362            Ok(PendingReply::SpawnAck {
363                value,
364                ok,
365                error: None,
366                stats,
367            }) => Ok(WorkerResult {
368                value,
369                ok,
370                // Operator-proxied stats (the harness reports the
371                // SubAgent's usage to the Operator, who attaches it to
372                // this ack). Best-effort decode — an unknown shape is
373                // dropped, never a spawn failure.
374                stats: stats.and_then(decode_ack_stats),
375            }),
376            Ok(PendingReply::SpawnAck {
377                error: Some(msg), ..
378            }) => Err(WorkerError::Failed(msg)),
379            // `spawn_halt` (issue #7): controlled halt. Return
380            // `Ok(WorkerResult { ok: true, value: halt_marker })` so the
381            // step lands as a normal termination rather than a
382            // `WorkerError::Failed` — log stays `info`, downstream retry
383            // logic doesn't fire. The halt marker carries the caller's
384            // partial value and reason string in a fixed shape.
385            Ok(PendingReply::SpawnHalt { value, reason }) => {
386                let marker = serde_json::json!({
387                    "halted": true,
388                    "reason": reason,
389                    "value": value,
390                });
391                Ok(WorkerResult {
392                    value: marker,
393                    ok: true,
394                    stats: None,
395                })
396            }
397            Ok(_) => Err(WorkerError::Failed(
398                "ws operator: unexpected non-spawn reply".into(),
399            )),
400            Err(e) => Err(WorkerError::Failed(format!("ws operator spawn: {e}"))),
401        }
402    }
403
404    fn requires_worker_binding(&self) -> bool {
405        true
406    }
407}
408
409/// Decode an ack-attached stats `Value` into
410/// [`mlua_swarm::store::trace::WorkerStats`], defaulting `worker_kind`
411/// to `"operator"` (the axis this ack rode in on). Best-effort: a shape
412/// that doesn't decode, or an all-empty stats object, maps to `None` —
413/// stats must never gate the ack itself.
414fn decode_ack_stats(v: serde_json::Value) -> Option<mlua_swarm::store::trace::WorkerStats> {
415    let mut stats: mlua_swarm::store::trace::WorkerStats = serde_json::from_value(v).ok()?;
416    if stats.worker_kind.is_none() {
417        stats.worker_kind = Some("operator".to_string());
418    }
419    if stats.is_empty() {
420        None
421    } else {
422        Some(stats)
423    }
424}
425
426/// Literal instruction text for the MainAI (= WS Client = Operator role). Fix
427/// for observation #7.
428///
429/// Minimal hand-off form parallel to /orch (agent_primitive): sends an
430/// `[agent_primitive dispatch=@<agent>]` marker + worker endpoint + auth +
431/// task_id in the payload; the MainAI **kicks a SubAgent by specifying AgentId +
432/// Token** and **forwards the return string verbatim into `SpawnAck.value`**.
433///
434/// The detailed instructions for the SubAgent are consolidated into the
435/// agent.md `system` (= the body fetched by `GET /v1/worker/prompt`); the
436/// directive is narrowed to the minimum routing information.
437///
438/// # `project_name_alias` / `project_root` / `work_dir` / `task_metadata`
439/// (GH #20 Contract C — `AgentContextView.to_directive_header`)
440///
441/// These task-level context header lines are no longer read individually
442/// here — they come off one materialized `view: &AgentContextView`
443/// (see `mlua_swarm::core::agent_context` for the full Contract C
444/// narrative) via [`AgentContextView::to_directive_header`], rendered
445/// verbatim at the top of the "worker endpoint" block below. Format is
446/// byte-identical to the pre-#20 individual splices
447/// (`project_name_alias: {a}` / `project_root: {p}` / `work_dir: {w}`,
448/// each independently absent-or-present, no empty-string placeholder) —
449/// the additive change is the new `task_metadata: {compact-json}` line
450/// (closes the F2 gap tracked in the `operator-execution-model` guide)
451/// plus one line per `view.extra` entry.
452///
453/// `project_name_alias` is ALSO used below (via `view.project_name_alias`)
454/// to expand the "LDS Session Alias" mandatory reminder block for the
455/// MainAI — the engine itself performs no other action on the alias; the
456/// expansion here is what the MainAI actually reads.
457///
458/// # `subagent_type` (Blueprint-baked worker binding)
459///
460/// Resolved from `AgentDef.profile.worker_binding` (see `WorkerBinding`) and
461/// literally substituted for the old hardcoded `"mse-worker"` string — the
462/// Blueprint is the single source of truth for which Claude Code SubAgent
463/// definition the MainAI must dispatch. There is deliberately **no fallback**
464/// to another `subagent_type` here: if the named SubAgent definition is not
465/// registered, the MainAI is instructed to fail the SpawnAck loud rather than
466/// silently substitute a different one.
467/// `base_url` is the server's public HTTP root (e.g.
468/// `"http://127.0.0.1:7777"`). When `Some`, it is rendered verbatim into
469/// the SubAgent prompt block so the operator can copy the frame
470/// straight through without a `mse_doctor` lookup (issue #8). When
471/// `None`, a fallback placeholder points the reader at `mse_doctor` —
472/// no fake port number appears in the directive.
473///
474/// `run_id` (issue #13 ID-hierarchy persistence) is `Some` whenever this
475/// dispatch's `Ctx.meta.runtime["run_id"]` is populated (see
476/// `Engine::dispatch_attempt_with`), and is rendered into the observation
477/// route hint below (`GET /v1/runs/{run_id}`) so a MainAI reading the
478/// directive can drill into that specific kick's `RunRecord.step_entries`
479/// trace. `None` falls back to a generic `<run_id>` placeholder. Kept as
480/// its own parameter (not read off `view`) — the directive's observation
481/// route hint is a separate rendering concern from the task-level context
482/// header.
483#[allow(clippy::too_many_arguments)]
484pub(super) fn default_spawn_directive(
485    agent: &str,
486    task_id: &str,
487    subagent_type: &str,
488    view: &AgentContextView,
489    data_sink_endpoint: Option<&str>,
490    base_url: Option<&str>,
491    run_id: Option<&str>,
492) -> String {
493    // GH #20: task-level context header lines (project_name_alias /
494    // project_root / work_dir / task_metadata / extra), rendered by the
495    // materialized view itself. See the doc above.
496    let context_header = view.to_directive_header();
497    // Endpoint hint for the Data path (Big Response routing). Only when
498    // Some, inject a convention line telling the MainAgent to pass the Big
499    // EMIT POST target URL into the SubAgent prompt or environment when it
500    // kicks a SubAgent. Audience: MainAgent (the SubAgent-launcher side).
501    // A single authenticated emit endpoint: the token can be passed as
502    // Bearer or `?token=`; both consume the same CapToken material.
503    let data_endpoint_block = match data_sink_endpoint {
504        Some(base) => format!(
505            "\n\
506             [Data path endpoint — MainAgent reminder]\n\
507             When you kick a SubAgent, inject the following two lines into\n\
508             its prompt / environment so Big Response payloads (4k+ tokens,\n\
509             files, intermediate artifacts) flow directly to the Store owner,\n\
510             bypassing the MainAgent (context stays small; only the out_id\n\
511             ref is passed around).\n  \
512             DATA_EMIT: {base}/v1/data/emit  (POST, auth = Bearer worker_handle or ?token=)\n  \
513             DATA_GET:  {base}/v1/data/<out_id|out_name>  (the next SubAgent fetches from $IN_REFS)\n\
514             When a SubAgent produces a Big Response, POST it to DATA_EMIT\n\
515             and return only the one-line out_id ref (do not mix the body\n\
516             in; the MainAgent must not answer directly).\n\
517             \n"
518        ),
519        None => String::new(),
520    };
521    let main_ai_reminder = match view.project_name_alias.as_deref() {
522        Some(a) => format!(
523            "\n\
524             [LDS Session Alias Reminder — MainAI mandatory]\n\
525             Before kicking the SubAgent below, call:\n  \
526             mcp__lds__session_create(root=<working_dir>, alias=\"{a}\")\n\
527             (= establish a single task-level lds session; reuse on repeated dispatch).\n\
528             Then add this literal line to the SubAgent prompt body below:\n  \
529             LDS Session Alias: {a}\n\
530             The SubAgent will call mcp__lds__session_start(alias=\"{a}\") on init,\n\
531             keeping worktree ownership unified across dispatches.\n\
532             (Full discipline rationale is inlined above; reach is via this directive itself,\n\
533              not via any external doc path. The 2 steps above are the complete contract.)\n\
534             \n"
535        ),
536        None => String::new(),
537    };
538    // Issue #8: render the actual server bind literally when it was
539    // sourced at boot; fall back to a pointer at `mse_doctor` rather
540    // than a fake port number.
541    let base_url_line = match base_url {
542        Some(u) => u.to_string(),
543        None => "<your server's actual bind — check with mse_doctor>".to_string(),
544    };
545    // issue #13: the real drill-down route is `GET /v1/runs/{run_id}` (a
546    // single `RunRecord`, `step_entries` trace included) — `GET
547    // /v1/tasks/{id}` does exist but returns the coarser `TaskRecord` +
548    // every `RunRecord` kicked from it, not this specific kick.
549    let run_route_line = match run_id {
550        Some(rid) => format!("GET <base_url>/v1/runs/{rid}"),
551        None => "GET <base_url>/v1/runs/<run_id>".to_string(),
552    };
553    format!(
554        "[agent_primitive dispatch=@{agent}]\n\
555         worker endpoint:\n  \
556         GET  <base_url>/v1/worker/prompt?task_id={task_id}\n  \
557         POST <base_url>/v1/worker/submit\n\
558         auth: Bearer <worker_handle from THIS Spawn payload (= short `wh-XXXXXXXX` form)>\n\
559         task_id: {task_id}\n\
560         agent_id: {agent}\n\
561         {context_header}\
562         {data_endpoint_block}\
563         {main_ai_reminder}\
564         Kick a SubAgent via Agent tool with subagent_type=\"{subagent_type}\" (= project-local \
565         `.claude/agents/{subagent_type}.md`, this agent's Blueprint-declared worker binding). \
566         The prompt you pass to it MUST be EXACTLY these 4 lines (no preamble, no extra text):\n\
567         \n  \
568         agent_id: {agent}\n  \
569         worker_handle: <THIS Spawn payload's `worker_handle` field (short string `wh-XXXXXXXX`)>\n  \
570         base_url: {base_url_line}\n  \
571         task_id: {task_id}\n\
572         \n\
573         The SubAgent self-fetches system + prompt via GET (Bearer = handle), \
574         executes as agent @{agent}, POSTs raw body to /v1/worker/submit (Bearer = handle, \
575         server resolves task_id from handle), and replies `OUTPUT` 1 word. You then forward \
576         SpawnAck {{req_id, value:{{}}, ok:true}} through your operator client — MCP path: \
577         mse_ack(sid, req_id, kind=\"spawn_ack\", ok=true) (= empty value because canonical \
578         body lives in output_tail via the POST). \
579         Do NOT fetch /v1/worker/prompt yourself. Do NOT wrap, summarize, or field-select \
580         the SubAgent reply. Observation / debug is a separate channel (= agent-inspect MCP / \
581         {run_route_line}), do NOT mix it into the forward path. \
582         If the SubAgent type is not registered, FAIL LOUD: reply SpawnAck ok=false with an \
583         error explaining the missing `.claude/agents/{subagent_type}.md` — do NOT fall back \
584         to another subagent_type."
585    )
586}
587
588/// Wraps [`default_spawn_directive`]'s routing/reminder text as the WS
589/// `Spawn.directive` `Value` (issue #18), additionally splicing in a
590/// `task_directive` line built from `TaskSpec.initial_directive` when the
591/// task was seeded with one.
592///
593/// This is the sole place the render from `Value` (`task_directive`) down
594/// to `String` literal happens for the WS Spawn path — the coercion that
595/// used to sit in `EngineDispatcher::dispatch` moved here. `task_directive
596/// == Value::Null` (no seed, or the caller could not recover one) omits
597/// the line entirely, leaving the output byte-identical to
598/// [`default_spawn_directive`]'s own text — this preserves every existing
599/// [`default_spawn_directive`] test unchanged, since that function's
600/// signature and body are untouched by issue #18.
601#[allow(clippy::too_many_arguments)]
602pub(super) fn default_spawn_directive_with_task_directive(
603    agent: &str,
604    task_id: &str,
605    subagent_type: &str,
606    view: &AgentContextView,
607    data_sink_endpoint: Option<&str>,
608    base_url: Option<&str>,
609    run_id: Option<&str>,
610    task_directive: &Value,
611) -> String {
612    let base = default_spawn_directive(
613        agent,
614        task_id,
615        subagent_type,
616        view,
617        data_sink_endpoint,
618        base_url,
619        run_id,
620    );
621    // Strings pass through verbatim; anything else (Object / Array /
622    // Number / Bool) is serde-stringified — the same coercion pattern
623    // `EngineDispatcher::dispatch` used to apply eagerly, now applied
624    // lazily at this render boundary only.
625    let task_directive_line = match task_directive {
626        Value::Null => String::new(),
627        Value::String(s) => format!("task_directive: {s}\n"),
628        other => format!("task_directive: {other}\n"),
629    };
630    format!("{base}{task_directive_line}")
631}
632
633/// issue #21/ST2 in-flight projection hook: projects `view` (the
634/// spawn-time, already `apply_policy`-filtered [`AgentContextView`] — see
635/// `AgentContextMiddleware`'s module doc) to file via a fresh
636/// [`FileProjectionAdapter`] rooted at the materialize root
637/// `placement.resolve_root(view)` resolves (GH #27, follow-up to #23 —
638/// see `mlua_swarm::core::projection_placement`'s module doc for the "3
639/// path" convergence this closes: this hook used to check `view.work_dir`
640/// ONLY, with no fallback to `view.project_root`, an asymmetry against
641/// the other two call sites the shared resolver now removes), and appends
642/// a single `ctx_projection: {json}\n` line to `directive` — a
643/// `{key}: {value}\n` splice matching [`AgentContextView::to_directive_header`]'s
644/// own line convention (e.g. its `task_metadata: {compact-json}\n` line),
645/// never the projected value itself (pointer-only supply; see
646/// `mlua_swarm::core::projection`'s module doc for why).
647///
648/// An unresolved root, `view` failing to serialize, or the materialize
649/// write itself failing, all fall back to `directive` unchanged (no
650/// pointer line) rather than failing the spawn — subtask-2's Invariants
651/// require this hook to never turn a would-have-succeeded spawn into a
652/// failure.
653///
654/// # projection-adapter ST5: `ctx_step_dir` line retired
655///
656/// This spawn-time `ctx_projection:` line supplies *this spawning agent's
657/// own* `AgentContextView` (kept, unchanged, above). A companion
658/// `ctx_step_dir:` line — pointing the worker at
659/// `<root>/workspace/tasks/<task_id>/ctx/` plus the `mse_ctx_get` MCP tool
660/// as a way to pull a *prior* step's OUTPUT out of it — existed from
661/// subtask-4 through ST4; ST5 retires both (see
662/// `mlua_swarm::core::agent_context`'s module doc): the Worker axis now
663/// gets prior steps' OUTPUT pointers automatically, pre-filtered through
664/// `ContextPolicy.steps`, on `AgentContextView.steps` (assembled by
665/// `crates/mlua-swarm-server/src/worker.rs`'s `GET /v1/worker/prompt`
666/// handler) — no separate directory hint or MCP tool call needed.
667fn append_projection_pointer(
668    directive: String,
669    task_id: &StepId,
670    view: &AgentContextView,
671    run_id: Option<&str>,
672    placement: &ProjectionPlacement,
673) -> String {
674    let Some(root) = placement.resolve_root(view) else {
675        return directive;
676    };
677    match serde_json::to_value(view) {
678        Ok(ctx_data) => {
679            let key = ProjectionKey {
680                task_id: task_id.to_string(),
681                run_id: run_id.map(str::to_string),
682                step: None,
683                path: None,
684            };
685            let adapter = FileProjectionAdapter::with_placement(root, placement.clone());
686            match adapter.project(&key, &ctx_data) {
687                Ok(reference) => {
688                    let pointer_value = match &reference {
689                        ProjectionRef::File { path } => serde_json::json!({ "file": path }),
690                        ProjectionRef::Query { endpoint, key } => {
691                            serde_json::json!({ "endpoint": endpoint, "key": key })
692                        }
693                    };
694                    format!("{directive}ctx_projection: {pointer_value}\n")
695                }
696                Err(err) => {
697                    tracing::warn!(
698                        %task_id,
699                        error = %err,
700                        "projection hook: materialize failed, spawning without a pointer"
701                    );
702                    directive
703                }
704            }
705        }
706        Err(err) => {
707            tracing::warn!(
708                %task_id,
709                error = %err,
710                "projection hook: AgentContextView serialize failed, spawning without a pointer"
711            );
712            directive
713        }
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720    use mlua_swarm::core::agent_context::{
721        TASK_METADATA_KEY, TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY,
722    };
723
724    /// Test helper: builds an `AgentContextView` with only
725    /// `project_name_alias` / `project_root` / `work_dir` set (the three
726    /// fields `default_spawn_directive`'s retired individual params used
727    /// to carry) — everything else stays at `Default`. Mirrors the
728    /// pre-#20 call shape so the mechanical rewrite of every existing
729    /// test stays a 1:1 argument swap.
730    fn view_with(
731        alias: Option<&str>,
732        project_root: Option<&str>,
733        work_dir: Option<&str>,
734    ) -> AgentContextView {
735        AgentContextView {
736            project_name_alias: alias.map(String::from),
737            project_root: project_root.map(String::from),
738            work_dir: work_dir.map(String::from),
739            ..AgentContextView::default()
740        }
741    }
742
743    #[tokio::test]
744    async fn connection_state_tracks_the_current_sender() {
745        let (tx, _rx) = mpsc::unbounded_channel();
746        let session = WSOperatorSession::new_with_base_url(
747            SessionId::parse("S-connection-state").unwrap(),
748            tx.clone(),
749            None,
750        );
751        assert!(session.is_connected().await);
752
753        session.clear_tx_if(&tx).await;
754        assert!(!session.is_connected().await);
755    }
756
757    #[tokio::test]
758    async fn stale_disconnect_does_not_clear_a_reconnected_sender() {
759        let (old_tx, _old_rx) = mpsc::unbounded_channel();
760        let (new_tx, _new_rx) = mpsc::unbounded_channel();
761        let session = WSOperatorSession::new_with_base_url(
762            SessionId::parse("S-reconnect-state").unwrap(),
763            old_tx.clone(),
764            None,
765        );
766
767        session.replace_tx(new_tx).await;
768        session.clear_tx_if(&old_tx).await;
769
770        assert!(session.is_connected().await);
771    }
772
773    #[test]
774    fn directive_omits_project_name_alias_when_none() {
775        let d = default_spawn_directive(
776            "impl-lead",
777            "task-x",
778            "mse-worker-coder",
779            &view_with(None, None, None),
780            None,
781            None,
782            None,
783        );
784        assert!(!d.contains("project_name_alias:"));
785        assert!(!d.contains("LDS Session Alias"));
786        assert!(!d.contains("session_create"));
787    }
788
789    #[test]
790    fn directive_emits_project_name_alias_when_some() {
791        let d = default_spawn_directive(
792            "impl-lead",
793            "task-x",
794            "mse-worker-coder",
795            &view_with(Some("mse-task-7785"), None, None),
796            None,
797            None,
798            None,
799        );
800        // Header line (expanded verbatim from the value).
801        assert!(
802            d.contains("project_name_alias: mse-task-7785"),
803            "directive missing project_name_alias header: {d}"
804        );
805        // MainAI mandatory reminder (= session_create + SubAgent prompt inject)
806        assert!(
807            d.contains("mcp__lds__session_create(root=<working_dir>, alias=\"mse-task-7785\")"),
808            "directive missing session_create reminder: {d}"
809        );
810        assert!(
811            d.contains("LDS Session Alias: mse-task-7785"),
812            "directive missing SubAgent prompt inject line: {d}"
813        );
814        // Reach discipline: the rationale is inlined into the directive (no external doc path reference).
815        assert!(
816            d.contains("inlined above") || d.contains("complete contract"),
817            "directive should inline rationale rather than point at external doc: {d}"
818        );
819        // The SoT is not pointed at an AI personal memory file (which is
820        // outside the MainAI's reach) — reach-axis consistency. Path
821        // references coming from the subagent registration convention (for
822        // example `agents/mse-worker.md`) are a separate case and are
823        // allowed. The pattern is assembled by string concat so that no
824        // gitignored dir literal remains in the source and the
825        // internal-doc-leak / secret-pre-commit-checker mechanical pattern
826        // match is avoided.
827        let forbidden_doc_ref = format!(".{}/CLAUDE.md", "claude");
828        assert!(
829            !d.contains(&forbidden_doc_ref),
830            "directive must not reference {forbidden_doc_ref} (out of MainAI scope): {d}"
831        );
832    }
833
834    #[test]
835    fn directive_omits_data_endpoint_when_none() {
836        let d = default_spawn_directive(
837            "impl-lead",
838            "task-x",
839            "mse-worker-coder",
840            &view_with(None, None, None),
841            None,
842            None,
843            None,
844        );
845        assert!(!d.contains("[Data path endpoint"));
846        assert!(!d.contains("DATA_EMIT"));
847        assert!(!d.contains("DATA_GET"));
848    }
849
850    #[test]
851    fn directive_emits_data_endpoint_when_some() {
852        let base = "http://127.0.0.1:7785";
853        let d = default_spawn_directive(
854            "impl-lead",
855            "task-x",
856            "mse-worker-coder",
857            &view_with(None, None, None),
858            Some(base),
859            None,
860            None,
861        );
862        assert!(
863            d.contains("[Data path endpoint"),
864            "directive missing data endpoint block header: {d}"
865        );
866        assert!(
867            d.contains(&format!("DATA_EMIT: {base}/v1/data/emit")),
868            "directive missing single-mouth emit line: {d}"
869        );
870        assert!(
871            d.contains("Bearer worker_handle or ?token="),
872            "directive missing auth transport hint: {d}"
873        );
874        assert!(
875            d.contains(&format!("DATA_GET:  {base}/v1/data/<out_id|out_name>")),
876            "directive missing GET line: {d}"
877        );
878        assert!(
879            !d.contains("emit-auth"),
880            "old split endpoint must not leak into directive: {d}"
881        );
882        assert!(
883            d.contains("bypassing the MainAgent") && d.contains("out_id ref"),
884            "directive should carry the ownership + bypass reasoning: {d}"
885        );
886    }
887
888    #[test]
889    fn directive_carries_declared_subagent_type_and_has_no_fallback() {
890        let d = default_spawn_directive(
891            "impl-lead",
892            "task-x",
893            "mse-worker-coder",
894            &view_with(None, None, None),
895            None,
896            None,
897            None,
898        );
899        assert!(
900            d.contains("subagent_type=\"mse-worker-coder\""),
901            "directive must carry the Blueprint-declared subagent_type literally: {d}"
902        );
903        assert!(
904            d.contains(".claude/agents/mse-worker-coder.md"),
905            "directive must reference the declared subagent's own .md path: {d}"
906        );
907        // The old hardcoded default and its silent-fallback text must be gone.
908        assert!(
909            !d.contains("general-purpose"),
910            "directive must not fall back to subagent_type=\"general-purpose\": {d}"
911        );
912        assert!(
913            !d.contains("mse-worker\""),
914            "directive must not carry the old hardcoded \"mse-worker\" literal: {d}"
915        );
916        assert!(
917            d.contains("FAIL LOUD"),
918            "directive must instruct the MainAI to fail loud instead of falling back: {d}"
919        );
920    }
921
922    // ─── Issue #8: base_url rendering + fallback framing ─────────────────
923
924    /// Layer 1: when `base_url` is `Some`, it must land verbatim in the
925    /// SubAgent-prompt block, so the operator can copy the frame
926    /// through without a `mse_doctor` lookup.
927    #[test]
928    fn directive_renders_actual_base_url_when_some() {
929        let d = default_spawn_directive(
930            "impl-lead",
931            "task-x",
932            "mse-worker-coder",
933            &view_with(None, None, None),
934            None,
935            Some("http://127.0.0.1:8888"),
936            None,
937        );
938        assert!(
939            d.contains("base_url: http://127.0.0.1:8888"),
940            "directive must render the actual bind literally: {d}"
941        );
942        assert!(
943            !d.contains("mse_doctor"),
944            "no mse_doctor detour when bind is known: {d}"
945        );
946    }
947
948    /// Layer 3: when `base_url` is `None` (unit tests, mock harnesses,
949    /// pre-serve rendering) the fallback line must point the reader at
950    /// `mse_doctor` — never a fake port number.
951    #[test]
952    fn directive_falls_back_to_mse_doctor_pointer_when_none() {
953        let d = default_spawn_directive(
954            "impl-lead",
955            "task-x",
956            "mse-worker-coder",
957            &view_with(None, None, None),
958            None,
959            None,
960            None,
961        );
962        assert!(
963            d.contains("check with mse_doctor"),
964            "fallback must point at mse_doctor: {d}"
965        );
966    }
967
968    /// Regression guard: the historical `7786` example port (the whole
969    /// origin of issue #8) must not survive in the rendered directive
970    /// under any input combination.
971    #[test]
972    fn directive_never_contains_stale_example_port_7786() {
973        for base in [
974            None,
975            Some("http://127.0.0.1:7777"),
976            Some("http://192.0.2.1:9000"),
977        ] {
978            let d = default_spawn_directive(
979                "impl-lead",
980                "task-x",
981                "mse-worker-coder",
982                &view_with(Some("mse-task-alias"), None, None),
983                Some("http://127.0.0.1:7785"),
984                base,
985                None,
986            );
987            assert!(
988                !d.contains("7786"),
989                "stale example port 7786 leaked: base={base:?}, d={d}"
990            );
991        }
992    }
993
994    // ─── Issue #13: run_id observation route (doc-drift fix) ─────────────
995
996    /// Regression guard: the stale `GET /v1/tasks/{id}` observation hint
997    /// (a route that never returns a single `RunRecord`) must be gone —
998    /// the directive must point at the real drill-down route instead.
999    #[test]
1000    fn directive_never_contains_stale_tasks_id_route() {
1001        let d = default_spawn_directive(
1002            "impl-lead",
1003            "task-x",
1004            "mse-worker-coder",
1005            &view_with(None, None, None),
1006            None,
1007            None,
1008            Some("R-abc123"),
1009        );
1010        assert!(
1011            !d.contains("/v1/tasks/{id}") && !d.contains("/v1/tasks/{{id}}"),
1012            "stale /v1/tasks/{{id}} observation hint leaked: {d}"
1013        );
1014    }
1015
1016    /// When `run_id` is `Some`, it is rendered literally into the
1017    /// observation route hint (`GET /v1/runs/<run_id>`).
1018    #[test]
1019    fn directive_renders_actual_run_id_when_some() {
1020        let d = default_spawn_directive(
1021            "impl-lead",
1022            "task-x",
1023            "mse-worker-coder",
1024            &view_with(None, None, None),
1025            None,
1026            None,
1027            Some("R-abc123"),
1028        );
1029        assert!(
1030            d.contains("GET <base_url>/v1/runs/R-abc123"),
1031            "directive missing real run_id in observation route: {d}"
1032        );
1033    }
1034
1035    /// `run_id: None` (no run tracing for this launch) falls back to a
1036    /// generic placeholder route rather than a stale/incorrect one.
1037    #[test]
1038    fn directive_falls_back_to_run_id_placeholder_when_none() {
1039        let d = default_spawn_directive(
1040            "impl-lead",
1041            "task-x",
1042            "mse-worker-coder",
1043            &view_with(None, None, None),
1044            None,
1045            None,
1046            None,
1047        );
1048        assert!(
1049            d.contains("GET <base_url>/v1/runs/<run_id>"),
1050            "directive missing placeholder observation route: {d}"
1051        );
1052    }
1053
1054    // ─── Issue #17: project_root / work_dir header lines ─────────────────
1055
1056    /// Both absent → neither header line appears (no empty-string
1057    /// placeholder either).
1058    #[test]
1059    fn directive_omits_project_root_and_work_dir_when_both_none() {
1060        let d = default_spawn_directive(
1061            "impl-lead",
1062            "task-x",
1063            "mse-worker-coder",
1064            &view_with(None, None, None),
1065            None,
1066            None,
1067            None,
1068        );
1069        assert!(!d.contains("project_root:"));
1070        assert!(!d.contains("work_dir:"));
1071    }
1072
1073    /// Both present → both header lines render literally, alongside
1074    /// `project_name_alias`'s existing splice.
1075    #[test]
1076    fn directive_splices_project_root_and_work_dir_when_both_present() {
1077        let d = default_spawn_directive(
1078            "impl-lead",
1079            "task-x",
1080            "mse-worker-coder",
1081            &view_with(None, Some("/repo"), Some("/repo/work")),
1082            None,
1083            None,
1084            None,
1085        );
1086        assert!(
1087            d.contains("project_root: /repo"),
1088            "directive missing project_root header: {d}"
1089        );
1090        assert!(
1091            d.contains("work_dir: /repo/work"),
1092            "directive missing work_dir header: {d}"
1093        );
1094    }
1095
1096    /// Partial: `project_root` present, `work_dir` absent — each field is
1097    /// independent, so only the present one renders.
1098    #[test]
1099    fn directive_splices_project_root_only_when_work_dir_absent() {
1100        let d = default_spawn_directive(
1101            "impl-lead",
1102            "task-x",
1103            "mse-worker-coder",
1104            &view_with(None, Some("/repo"), None),
1105            None,
1106            None,
1107            None,
1108        );
1109        assert!(
1110            d.contains("project_root: /repo"),
1111            "directive missing project_root header: {d}"
1112        );
1113        assert!(!d.contains("work_dir:"));
1114    }
1115
1116    // ─── GH #20: task_metadata header line (Contract C, closes the F2 gap) ─
1117
1118    /// `task_metadata` renders as a new `task_metadata: {compact-json}`
1119    /// line — the F2 gap the `operator-execution-model` guide tracked
1120    /// (`task_metadata`'s inner keys were never spliced into the
1121    /// directive before GH #20).
1122    #[test]
1123    fn directive_splices_task_metadata_when_some() {
1124        let view = AgentContextView {
1125            task_metadata: Some(serde_json::json!({"issue": 20})),
1126            ..view_with(None, Some("/repo"), None)
1127        };
1128        let d = default_spawn_directive(
1129            "impl-lead",
1130            "task-x",
1131            "mse-worker-coder",
1132            &view,
1133            None,
1134            None,
1135            None,
1136        );
1137        assert!(
1138            d.contains(r#"task_metadata: {"issue":20}"#),
1139            "directive missing task_metadata header: {d}"
1140        );
1141        // Additive-only: the pre-existing project_root line still renders.
1142        assert!(d.contains("project_root: /repo"));
1143    }
1144
1145    /// `task_metadata: None` (absent) omits the line entirely — no
1146    /// empty-string placeholder, matching every other header line's
1147    /// absent-field contract.
1148    #[test]
1149    fn directive_omits_task_metadata_when_none() {
1150        let d = default_spawn_directive(
1151            "impl-lead",
1152            "task-x",
1153            "mse-worker-coder",
1154            &view_with(None, None, None),
1155            None,
1156            None,
1157            None,
1158        );
1159        assert!(!d.contains("task_metadata:"));
1160    }
1161
1162    // ─── Issue #7: spawn_halt handling in Operator::execute ──────────────
1163
1164    fn test_ctx(task_id: &str) -> mlua_swarm::Ctx {
1165        mlua_swarm::Ctx::new(mlua_swarm::StepId::parse(task_id).unwrap(), 1, "a")
1166    }
1167
1168    fn test_worker_binding() -> mlua_swarm::WorkerBinding {
1169        mlua_swarm::WorkerBinding {
1170            variant: "test-variant".into(),
1171            tools: vec![],
1172            request_digest: None,
1173            requested_model: None,
1174        }
1175    }
1176
1177    fn test_cap_token() -> mlua_swarm::CapToken {
1178        mlua_swarm::CapToken {
1179            agent_id: "a".into(),
1180            role: mlua_swarm::Role::Worker,
1181            scopes: vec!["*".into()],
1182            issued_at: 0,
1183            expire_at: u64::MAX / 2,
1184            max_uses: None,
1185            nonce: "test-nonce".into(),
1186            sig_hex: "".into(),
1187        }
1188    }
1189
1190    /// A `PendingReply::SpawnHalt` reply must translate into a
1191    /// `Ok(WorkerResult { ok: true, value: <halt marker> })` — a normal
1192    /// termination, not a `WorkerError::Failed` (fail-loud). This is
1193    /// the whole point of the new verb: distinguishing a controlled
1194    /// halt from a real worker error at the log / retry-signal level.
1195    #[tokio::test]
1196    async fn spawn_halt_reply_lands_as_ok_worker_result_with_marker() {
1197        use mlua_swarm::Operator;
1198        use tokio::sync::mpsc;
1199
1200        let (tx, mut rx) = mpsc::unbounded_channel();
1201        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1202            SessionId::parse("S-halt").unwrap(),
1203            tx,
1204            None,
1205        ));
1206
1207        // Kick execute() in a background task so we can grab the
1208        // req_id the server assigns and inject a matching SpawnHalt.
1209        let session_bg = session.clone();
1210        let handle = tokio::spawn(async move {
1211            session_bg
1212                .execute(
1213                    &test_ctx("ST-halt"),
1214                    None,
1215                    "".into(),
1216                    Some(test_worker_binding()),
1217                    test_cap_token(),
1218                )
1219                .await
1220        });
1221
1222        let sent = rx.recv().await.expect("Spawn sent");
1223        let req_id = match sent {
1224            ServerMsg::Spawn { req_id, .. } => req_id,
1225            other => panic!("expected Spawn, got {other:?}"),
1226        };
1227
1228        session
1229            .resolve_pending(
1230                &req_id,
1231                PendingReply::SpawnHalt {
1232                    value: serde_json::json!({"partial": "abc"}),
1233                    reason: Some("shape verified".into()),
1234                },
1235            )
1236            .await;
1237
1238        let result = handle.await.expect("join").expect("execute Ok");
1239        assert!(
1240            result.ok,
1241            "spawn_halt must land as ok=true (normal termination), got: {result:?}"
1242        );
1243        assert_eq!(result.value["halted"], true);
1244        assert_eq!(result.value["reason"], "shape verified");
1245        assert_eq!(result.value["value"], serde_json::json!({"partial": "abc"}));
1246    }
1247
1248    /// `spawn_ack { ok: false, error: Some(_) }` must retain its
1249    /// current fail-loud behaviour (backward compat guard).
1250    #[tokio::test]
1251    async fn spawn_ack_with_error_still_lands_as_worker_error() {
1252        use mlua_swarm::{Operator, WorkerError};
1253        use tokio::sync::mpsc;
1254
1255        let (tx, mut rx) = mpsc::unbounded_channel();
1256        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1257            SessionId::parse("S-err").unwrap(),
1258            tx,
1259            None,
1260        ));
1261
1262        let session_bg = session.clone();
1263        let handle = tokio::spawn(async move {
1264            session_bg
1265                .execute(
1266                    &test_ctx("ST-err"),
1267                    None,
1268                    "".into(),
1269                    Some(test_worker_binding()),
1270                    test_cap_token(),
1271                )
1272                .await
1273        });
1274
1275        let sent = rx.recv().await.expect("Spawn sent");
1276        let req_id = match sent {
1277            ServerMsg::Spawn { req_id, .. } => req_id,
1278            other => panic!("expected Spawn, got {other:?}"),
1279        };
1280
1281        session
1282            .resolve_pending(
1283                &req_id,
1284                PendingReply::SpawnAck {
1285                    value: serde_json::json!({}),
1286                    ok: false,
1287                    error: Some("real crash".into()),
1288                    stats: None,
1289                },
1290            )
1291            .await;
1292
1293        let err = handle.await.expect("join").expect_err("must be error");
1294        assert!(matches!(err, WorkerError::Failed(msg) if msg.contains("real crash")));
1295    }
1296
1297    /// B-2: a spawn parked in `execute` (awaiting a `SpawnAck` that never
1298    /// arrives) must unblock with a `WorkerError::Failed` as soon as
1299    /// `fail_pending` drains the pending map — the teardown path's
1300    /// immediate-fail guarantee, so a torn-down session does not leave a
1301    /// spawn orphaned until the run's sync timeout fires.
1302    #[tokio::test]
1303    async fn fail_pending_unblocks_a_parked_spawn_with_worker_error() {
1304        use mlua_swarm::{Operator, WorkerError};
1305        use tokio::sync::mpsc;
1306
1307        let (tx, mut rx) = mpsc::unbounded_channel();
1308        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1309            SessionId::parse("S-teardown").unwrap(),
1310            tx,
1311            None,
1312        ));
1313
1314        let session_bg = session.clone();
1315        let handle = tokio::spawn(async move {
1316            session_bg
1317                .execute(
1318                    &test_ctx("ST-teardown"),
1319                    None,
1320                    "".into(),
1321                    Some(test_worker_binding()),
1322                    test_cap_token(),
1323                )
1324                .await
1325        });
1326
1327        // Wait until the Spawn is actually parked (its pending entry is
1328        // registered) before tearing down, so the drain has something to
1329        // fail rather than racing the insert.
1330        let _sent = rx.recv().await.expect("Spawn sent");
1331
1332        session.fail_pending("operator session torn down").await;
1333
1334        let err = handle
1335            .await
1336            .expect("join")
1337            .expect_err("a parked spawn must fail once pending is drained");
1338        assert!(
1339            matches!(err, WorkerError::Failed(_)),
1340            "fail_pending must surface a WorkerError::Failed, got: {err:?}"
1341        );
1342    }
1343
1344    // ─── Issue #17: end-to-end `execute()` splice (ctx.meta.runtime → Spawn.directive) ───
1345
1346    /// `Ctx.meta.runtime` carrying both `project_root` and `work_dir`
1347    /// (the `TaskInputMiddleware` injection shape) must land in the
1348    /// `ServerMsg::Spawn.directive` actually sent over the wire — not
1349    /// just in the pure `default_spawn_directive` helper.
1350    #[tokio::test]
1351    async fn execute_splices_project_root_and_work_dir_from_ctx_meta_runtime() {
1352        use mlua_swarm::Operator;
1353        use tokio::sync::mpsc;
1354
1355        let (tx, mut rx) = mpsc::unbounded_channel();
1356        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1357            SessionId::parse("S-ctxroot").unwrap(),
1358            tx,
1359            None,
1360        ));
1361
1362        let mut ctx = test_ctx("ST-ctxroot");
1363        ctx.meta.runtime.insert(
1364            TASK_PROJECT_ROOT_KEY.to_string(),
1365            serde_json::json!("/repo"),
1366        );
1367        ctx.meta.runtime.insert(
1368            TASK_WORK_DIR_KEY.to_string(),
1369            serde_json::json!("/repo/work"),
1370        );
1371
1372        let session_bg = session.clone();
1373        let handle = tokio::spawn(async move {
1374            session_bg
1375                .execute(
1376                    &ctx,
1377                    None,
1378                    "".into(),
1379                    Some(test_worker_binding()),
1380                    test_cap_token(),
1381                )
1382                .await
1383        });
1384
1385        let sent = rx.recv().await.expect("Spawn sent");
1386        let req_id = match sent {
1387            ServerMsg::Spawn {
1388                req_id, directive, ..
1389            } => {
1390                // issue #18: `Spawn.directive` is now `Value`; extract the
1391                // `String` it wraps (always a `Value::String` on this
1392                // path — see `default_spawn_directive_with_task_directive`).
1393                let directive = directive.as_str();
1394                assert!(
1395                    directive.contains("project_root: /repo"),
1396                    "directive missing project_root splice: {directive}"
1397                );
1398                assert!(
1399                    directive.contains("work_dir: /repo/work"),
1400                    "directive missing work_dir splice: {directive}"
1401                );
1402                req_id
1403            }
1404            other => panic!("expected Spawn, got {other:?}"),
1405        };
1406
1407        session
1408            .resolve_pending(
1409                &req_id,
1410                PendingReply::SpawnAck {
1411                    value: serde_json::json!({}),
1412                    ok: true,
1413                    error: None,
1414                    stats: None,
1415                },
1416            )
1417            .await;
1418        handle.await.expect("join").expect("execute Ok");
1419    }
1420
1421    /// Partial: only `project_root` present in `ctx.meta.runtime` (no
1422    /// `TaskInputMiddleware`-populated `work_dir`) — the splice is
1423    /// per-field independent, matching `TaskInputMiddleware`'s own
1424    /// per-field-optional contract.
1425    #[tokio::test]
1426    async fn execute_splices_project_root_only_when_ctx_meta_runtime_partial() {
1427        use mlua_swarm::Operator;
1428        use tokio::sync::mpsc;
1429
1430        let (tx, mut rx) = mpsc::unbounded_channel();
1431        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1432            SessionId::parse("S-ctxpartial").unwrap(),
1433            tx,
1434            None,
1435        ));
1436
1437        let mut ctx = test_ctx("ST-ctxpartial");
1438        ctx.meta.runtime.insert(
1439            TASK_PROJECT_ROOT_KEY.to_string(),
1440            serde_json::json!("/repo"),
1441        );
1442
1443        let session_bg = session.clone();
1444        let handle = tokio::spawn(async move {
1445            session_bg
1446                .execute(
1447                    &ctx,
1448                    None,
1449                    "".into(),
1450                    Some(test_worker_binding()),
1451                    test_cap_token(),
1452                )
1453                .await
1454        });
1455
1456        let sent = rx.recv().await.expect("Spawn sent");
1457        let req_id = match sent {
1458            ServerMsg::Spawn {
1459                req_id, directive, ..
1460            } => {
1461                let directive = directive.as_str();
1462                assert!(
1463                    directive.contains("project_root: /repo"),
1464                    "directive missing project_root splice: {directive}"
1465                );
1466                assert!(!directive.contains("work_dir:"));
1467                req_id
1468            }
1469            other => panic!("expected Spawn, got {other:?}"),
1470        };
1471
1472        session
1473            .resolve_pending(
1474                &req_id,
1475                PendingReply::SpawnAck {
1476                    value: serde_json::json!({}),
1477                    ok: true,
1478                    error: None,
1479                    stats: None,
1480                },
1481            )
1482            .await;
1483        handle.await.expect("join").expect("execute Ok");
1484    }
1485
1486    /// Neither present in `ctx.meta.runtime` (no `TaskInputMiddleware`
1487    /// layered for this launch) — the directive carries neither header
1488    /// line, matching pre-issue-#17 behavior exactly.
1489    #[tokio::test]
1490    async fn execute_omits_project_root_and_work_dir_when_ctx_meta_runtime_absent() {
1491        use mlua_swarm::Operator;
1492        use tokio::sync::mpsc;
1493
1494        let (tx, mut rx) = mpsc::unbounded_channel();
1495        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1496            SessionId::parse("S-ctxabsent").unwrap(),
1497            tx,
1498            None,
1499        ));
1500
1501        let ctx = test_ctx("ST-ctxabsent");
1502
1503        let session_bg = session.clone();
1504        let handle = tokio::spawn(async move {
1505            session_bg
1506                .execute(
1507                    &ctx,
1508                    None,
1509                    "".into(),
1510                    Some(test_worker_binding()),
1511                    test_cap_token(),
1512                )
1513                .await
1514        });
1515
1516        let sent = rx.recv().await.expect("Spawn sent");
1517        let req_id = match sent {
1518            ServerMsg::Spawn {
1519                req_id, directive, ..
1520            } => {
1521                let directive = directive.as_str();
1522                assert!(!directive.contains("project_root:"));
1523                assert!(!directive.contains("work_dir:"));
1524                req_id
1525            }
1526            other => panic!("expected Spawn, got {other:?}"),
1527        };
1528
1529        session
1530            .resolve_pending(
1531                &req_id,
1532                PendingReply::SpawnAck {
1533                    value: serde_json::json!({}),
1534                    ok: true,
1535                    error: None,
1536                    stats: None,
1537                },
1538            )
1539            .await;
1540        handle.await.expect("join").expect("execute Ok");
1541    }
1542
1543    /// GH #20 / F2 gap: `task_metadata` in `ctx.meta.runtime` (the
1544    /// `TaskInputMiddleware` injection shape) now reaches the
1545    /// `ServerMsg::Spawn.directive` actually sent over the wire, via
1546    /// `AgentContextView::materialized_or_from_ctx` falling back to
1547    /// `from_ctx` when `AgentContextMiddleware` was not layered.
1548    #[tokio::test]
1549    async fn execute_splices_task_metadata_from_ctx_meta_runtime() {
1550        use mlua_swarm::Operator;
1551        use tokio::sync::mpsc;
1552
1553        let (tx, mut rx) = mpsc::unbounded_channel();
1554        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1555            SessionId::parse("S-ctxmeta").unwrap(),
1556            tx,
1557            None,
1558        ));
1559
1560        let mut ctx = test_ctx("ST-ctxmeta");
1561        ctx.meta.runtime.insert(
1562            TASK_METADATA_KEY.to_string(),
1563            serde_json::json!({"issue": 20}),
1564        );
1565
1566        let session_bg = session.clone();
1567        let handle = tokio::spawn(async move {
1568            session_bg
1569                .execute(
1570                    &ctx,
1571                    None,
1572                    "".into(),
1573                    Some(test_worker_binding()),
1574                    test_cap_token(),
1575                )
1576                .await
1577        });
1578
1579        let sent = rx.recv().await.expect("Spawn sent");
1580        let req_id = match sent {
1581            ServerMsg::Spawn {
1582                req_id, directive, ..
1583            } => {
1584                let directive = directive.as_str();
1585                assert!(
1586                    directive.contains(r#"task_metadata: {"issue":20}"#),
1587                    "directive missing task_metadata splice: {directive}"
1588                );
1589                req_id
1590            }
1591            other => panic!("expected Spawn, got {other:?}"),
1592        };
1593
1594        session
1595            .resolve_pending(
1596                &req_id,
1597                PendingReply::SpawnAck {
1598                    value: serde_json::json!({}),
1599                    ok: true,
1600                    error: None,
1601                    stats: None,
1602                },
1603            )
1604            .await;
1605        handle.await.expect("join").expect("execute Ok");
1606    }
1607
1608    // ─── Issue #18: `Value` pass-through render boundary
1609    //     (`default_spawn_directive_with_task_directive`) ───
1610
1611    /// A `String` seed splices in verbatim, unquoted (matching
1612    /// `Value::String(s) => s.clone()` — no JSON-quoting artifact).
1613    #[test]
1614    fn with_task_directive_splices_string_seed_verbatim() {
1615        let directive = default_spawn_directive_with_task_directive(
1616            "impl-lead",
1617            "task-x",
1618            "mse-worker-coder",
1619            &view_with(None, None, None),
1620            None,
1621            None,
1622            None,
1623            &serde_json::json!("do the thing"),
1624        );
1625        let text = directive.as_str();
1626        assert!(
1627            text.contains("task_directive: do the thing"),
1628            "missing task_directive line for a String seed: {text}"
1629        );
1630    }
1631
1632    /// An Object seed renders as its JSON literal (issue #18 Invariant 3 —
1633    /// same shape `Engine::start_task` / `Engine::dispatch_attempt_with`
1634    /// produce for the Worker HTTP path via `render_directive_to_string`).
1635    #[test]
1636    fn with_task_directive_renders_object_seed_as_json_literal() {
1637        let directive = default_spawn_directive_with_task_directive(
1638            "impl-lead",
1639            "task-x",
1640            "mse-worker-coder",
1641            &view_with(None, None, None),
1642            None,
1643            None,
1644            None,
1645            &serde_json::json!({"key": "value"}),
1646        );
1647        let text = directive.as_str();
1648        assert!(
1649            text.contains(r#"task_directive: {"key":"value"}"#),
1650            "missing JSON-literal task_directive line for an Object seed: {text}"
1651        );
1652    }
1653
1654    /// `Value::Null` (no seed recovered) omits the line entirely — the
1655    /// output is byte-identical to `default_spawn_directive`'s own text,
1656    /// preserving every pre-issue-#18 caller unchanged.
1657    #[test]
1658    fn with_task_directive_omits_line_when_null() {
1659        let wrapped = default_spawn_directive_with_task_directive(
1660            "impl-lead",
1661            "task-x",
1662            "mse-worker-coder",
1663            &view_with(None, None, None),
1664            None,
1665            None,
1666            None,
1667            &serde_json::Value::Null,
1668        );
1669        let plain = default_spawn_directive(
1670            "impl-lead",
1671            "task-x",
1672            "mse-worker-coder",
1673            &view_with(None, None, None),
1674            None,
1675            None,
1676            None,
1677        );
1678        assert_eq!(
1679            wrapped,
1680            serde_json::Value::String(plain),
1681            "Value::Null seed must not add a task_directive line"
1682        );
1683    }
1684
1685    /// End-to-end via `execute()`: an Object-shaped `Step.in` seed, once
1686    /// rendered to a JSON-literal `String` by the engine (the Worker HTTP
1687    /// path's `render_directive_to_string`), reaches `ServerMsg::Spawn`
1688    /// with the same JSON literal spliced into `directive` — the WS
1689    /// render layer is the sole `Value → String` coercion point on this
1690    /// path (issue #18).
1691    #[tokio::test]
1692    async fn execute_splices_json_literal_task_directive_for_object_seed() {
1693        use mlua_swarm::Operator;
1694        use tokio::sync::mpsc;
1695
1696        let (tx, mut rx) = mpsc::unbounded_channel();
1697        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1698            SessionId::parse("S-objseed").unwrap(),
1699            tx,
1700            None,
1701        ));
1702
1703        let ctx = test_ctx("ST-objseed");
1704        // Issue #18: `Value` flows end-to-end from `Step.in` through the
1705        // engine, so the Object seed reaches `execute()` as `Value` — no
1706        // stringification upstream. Only the WS Spawn frame render
1707        // performs the `Value → String` coercion.
1708        let rendered_prompt = serde_json::json!({"key": "value"});
1709
1710        let session_bg = session.clone();
1711        let handle = tokio::spawn(async move {
1712            session_bg
1713                .execute(
1714                    &ctx,
1715                    None,
1716                    rendered_prompt,
1717                    Some(test_worker_binding()),
1718                    test_cap_token(),
1719                )
1720                .await
1721        });
1722
1723        let sent = rx.recv().await.expect("Spawn sent");
1724        let req_id = match sent {
1725            ServerMsg::Spawn {
1726                req_id, directive, ..
1727            } => {
1728                let directive = directive.as_str();
1729                assert!(
1730                    directive.contains(r#"task_directive: {"key":"value"}"#),
1731                    "directive missing JSON-literal task_directive splice: {directive}"
1732                );
1733                req_id
1734            }
1735            other => panic!("expected Spawn, got {other:?}"),
1736        };
1737
1738        session
1739            .resolve_pending(
1740                &req_id,
1741                PendingReply::SpawnAck {
1742                    value: serde_json::json!({}),
1743                    ok: true,
1744                    error: None,
1745                    stats: None,
1746                },
1747            )
1748            .await;
1749        handle.await.expect("join").expect("execute Ok");
1750    }
1751
1752    // ─── issue #21/ST2: in-flight projection hook (`append_projection_pointer`) ───
1753
1754    /// `view.work_dir` present → the spawn directive carries a
1755    /// `ctx_projection:` pointer line, and the pointed-at file actually
1756    /// exists on disk (subtask-2 Tests #3).
1757    #[tokio::test]
1758    async fn execute_with_work_dir_appends_ctx_projection_pointer_and_materializes_file() {
1759        use mlua_swarm::Operator;
1760        use tokio::sync::mpsc;
1761
1762        let dir = tempfile::TempDir::new().unwrap();
1763        let mut ctx = test_ctx("ST-proj-1");
1764        ctx.meta.runtime.insert(
1765            TASK_WORK_DIR_KEY.to_string(),
1766            Value::String(dir.path().to_string_lossy().into_owned()),
1767        );
1768
1769        let (tx, mut rx) = mpsc::unbounded_channel();
1770        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1771            SessionId::parse("S-proj-1").unwrap(),
1772            tx,
1773            None,
1774        ));
1775
1776        let session_bg = session.clone();
1777        let handle = tokio::spawn(async move {
1778            session_bg
1779                .execute(
1780                    &ctx,
1781                    None,
1782                    "".into(),
1783                    Some(test_worker_binding()),
1784                    test_cap_token(),
1785                )
1786                .await
1787        });
1788
1789        let sent = rx.recv().await.expect("Spawn sent");
1790        let req_id = match sent {
1791            ServerMsg::Spawn {
1792                req_id, directive, ..
1793            } => {
1794                assert!(
1795                    directive.contains("ctx_projection:"),
1796                    "directive missing ctx_projection pointer line: {directive}"
1797                );
1798                // ST5 (`projection-adapter`) removal confirmation: the
1799                // pre-ST5 `ctx_step_dir:` companion line (pointing a
1800                // worker at the raw materialize directory + the retired
1801                // `mse_ctx_get` MCP tool) must never reappear — the
1802                // Worker axis now gets prior steps' OUTPUT pointers
1803                // automatically via `context.steps`.
1804                assert!(
1805                    !directive.contains("ctx_step_dir:"),
1806                    "directive must not carry the retired ctx_step_dir line: {directive}"
1807                );
1808                req_id
1809            }
1810            other => panic!("expected Spawn, got {other:?}"),
1811        };
1812
1813        session
1814            .resolve_pending(
1815                &req_id,
1816                PendingReply::SpawnAck {
1817                    value: serde_json::json!({}),
1818                    ok: true,
1819                    error: None,
1820                    stats: None,
1821                },
1822            )
1823            .await;
1824        handle.await.expect("join").expect("execute Ok");
1825
1826        let expected_file = dir.path().join("workspace/tasks/ST-proj-1/ctx/_ctx.md");
1827        assert!(
1828            expected_file.exists(),
1829            "materialized projection file missing at {expected_file:?}"
1830        );
1831    }
1832
1833    /// `view.work_dir` absent → the spawn directive carries no
1834    /// `ctx_projection:` line, and the spawn still succeeds (non-fatal
1835    /// fallback, subtask-2 Tests #4 + Invariant "must never turn a
1836    /// would-have-succeeded spawn into a failure").
1837    #[tokio::test]
1838    async fn execute_without_work_dir_spawns_without_ctx_projection_pointer() {
1839        use mlua_swarm::Operator;
1840        use tokio::sync::mpsc;
1841
1842        let (tx, mut rx) = mpsc::unbounded_channel();
1843        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1844            SessionId::parse("S-proj-2").unwrap(),
1845            tx,
1846            None,
1847        ));
1848
1849        let session_bg = session.clone();
1850        let handle = tokio::spawn(async move {
1851            session_bg
1852                .execute(
1853                    &test_ctx("ST-proj-2"),
1854                    None,
1855                    "".into(),
1856                    Some(test_worker_binding()),
1857                    test_cap_token(),
1858                )
1859                .await
1860        });
1861
1862        let sent = rx.recv().await.expect("Spawn sent");
1863        let req_id = match sent {
1864            ServerMsg::Spawn {
1865                req_id, directive, ..
1866            } => {
1867                assert!(
1868                    !directive.contains("ctx_projection:"),
1869                    "directive must not carry a pointer line when work_dir is absent \
1870                     (fallback): {directive}"
1871                );
1872                req_id
1873            }
1874            other => panic!("expected Spawn, got {other:?}"),
1875        };
1876
1877        session
1878            .resolve_pending(
1879                &req_id,
1880                PendingReply::SpawnAck {
1881                    value: serde_json::json!({}),
1882                    ok: true,
1883                    error: None,
1884                    stats: None,
1885                },
1886            )
1887            .await;
1888        handle
1889            .await
1890            .expect("join")
1891            .expect("execute Ok — a materialize skip must not fail the spawn");
1892    }
1893
1894    // ──────────────────────────────────────────────────────────────
1895    // GH #27 (follow-up to #23): ProjectionPlacement resolver wiring
1896    // ──────────────────────────────────────────────────────────────
1897
1898    /// `view.work_dir` ABSENT but `view.project_root` present, with the
1899    /// byte-compat default `ProjectionPlacement` (`root_preference =
1900    /// WorkDir`, falling back to `project_root`) — the asymmetry fix: a
1901    /// pre-GH-#27 build would have skipped the pointer entirely here
1902    /// (`view.work_dir` ONLY, no fallback); this build now falls back the
1903    /// SAME way the submit-time sink and server read-back always did.
1904    #[tokio::test]
1905    async fn execute_with_project_root_only_appends_ctx_projection_pointer_default_placement() {
1906        use mlua_swarm::Operator;
1907        use tokio::sync::mpsc;
1908
1909        let dir = tempfile::TempDir::new().unwrap();
1910        let mut ctx = test_ctx("ST-proj-3");
1911        ctx.meta.runtime.insert(
1912            TASK_PROJECT_ROOT_KEY.to_string(),
1913            Value::String(dir.path().to_string_lossy().into_owned()),
1914        );
1915
1916        let (tx, mut rx) = mpsc::unbounded_channel();
1917        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1918            SessionId::parse("S-proj-3").unwrap(),
1919            tx,
1920            None,
1921        ));
1922
1923        let session_bg = session.clone();
1924        let handle = tokio::spawn(async move {
1925            session_bg
1926                .execute(
1927                    &ctx,
1928                    None,
1929                    "".into(),
1930                    Some(test_worker_binding()),
1931                    test_cap_token(),
1932                )
1933                .await
1934        });
1935
1936        let sent = rx.recv().await.expect("Spawn sent");
1937        let req_id = match sent {
1938            ServerMsg::Spawn {
1939                req_id, directive, ..
1940            } => {
1941                assert!(
1942                    directive.contains("ctx_projection:"),
1943                    "work_dir absent must still fall back to project_root: {directive}"
1944                );
1945                req_id
1946            }
1947            other => panic!("expected Spawn, got {other:?}"),
1948        };
1949
1950        session
1951            .resolve_pending(
1952                &req_id,
1953                PendingReply::SpawnAck {
1954                    value: serde_json::json!({}),
1955                    ok: true,
1956                    error: None,
1957                    stats: None,
1958                },
1959            )
1960            .await;
1961        handle.await.expect("join").expect("execute Ok");
1962
1963        let expected_file = dir.path().join("workspace/tasks/ST-proj-3/ctx/_ctx.md");
1964        assert!(
1965            expected_file.exists(),
1966            "materialized projection file missing at {expected_file:?}"
1967        );
1968    }
1969
1970    /// A `ProjectionPlacement` stashed into
1971    /// `ctx.meta.runtime[PROJECTION_PLACEMENT_KEY]` (the same channel
1972    /// `AgentContextMiddleware` populates at spawn time) with
1973    /// `root_preference = ProjectRoot` and a custom `dir_template` changes
1974    /// BOTH which root is preferred (even though `work_dir` is ALSO
1975    /// present) AND the target directory layout the in-flight pointer
1976    /// materializes to.
1977    #[tokio::test]
1978    async fn execute_with_custom_projection_placement_uses_declared_root_and_template() {
1979        use mlua_swarm::core::projection_placement::{ProjectionPlacement, RootPreference};
1980        use mlua_swarm::Operator;
1981        use tokio::sync::mpsc;
1982
1983        let work_dir = tempfile::TempDir::new().unwrap();
1984        let project_root = tempfile::TempDir::new().unwrap();
1985        let mut ctx = test_ctx("ST-proj-4");
1986        ctx.meta.runtime.insert(
1987            TASK_WORK_DIR_KEY.to_string(),
1988            Value::String(work_dir.path().to_string_lossy().into_owned()),
1989        );
1990        ctx.meta.runtime.insert(
1991            TASK_PROJECT_ROOT_KEY.to_string(),
1992            Value::String(project_root.path().to_string_lossy().into_owned()),
1993        );
1994        let placement = ProjectionPlacement {
1995            root_preference: RootPreference::ProjectRoot,
1996            dir_template: "custom/{task_id}/out".to_string(),
1997        };
1998        ctx.meta.runtime.insert(
1999            PROJECTION_PLACEMENT_KEY.to_string(),
2000            serde_json::to_value(&placement).expect("placement serializes"),
2001        );
2002
2003        let (tx, mut rx) = mpsc::unbounded_channel();
2004        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
2005            SessionId::parse("S-proj-4").unwrap(),
2006            tx,
2007            None,
2008        ));
2009
2010        let session_bg = session.clone();
2011        let handle = tokio::spawn(async move {
2012            session_bg
2013                .execute(
2014                    &ctx,
2015                    None,
2016                    "".into(),
2017                    Some(test_worker_binding()),
2018                    test_cap_token(),
2019                )
2020                .await
2021        });
2022
2023        let sent = rx.recv().await.expect("Spawn sent");
2024        let req_id = match sent {
2025            ServerMsg::Spawn {
2026                req_id, directive, ..
2027            } => {
2028                assert!(
2029                    directive.contains("ctx_projection:"),
2030                    "directive missing ctx_projection pointer line: {directive}"
2031                );
2032                req_id
2033            }
2034            other => panic!("expected Spawn, got {other:?}"),
2035        };
2036
2037        session
2038            .resolve_pending(
2039                &req_id,
2040                PendingReply::SpawnAck {
2041                    value: serde_json::json!({}),
2042                    ok: true,
2043                    error: None,
2044                    stats: None,
2045                },
2046            )
2047            .await;
2048        handle.await.expect("join").expect("execute Ok");
2049
2050        let expected_file = project_root.path().join("custom/ST-proj-4/out/_ctx.md");
2051        assert!(
2052            expected_file.exists(),
2053            "materialized projection file missing at custom placement target {expected_file:?}"
2054        );
2055        let unexpected_file = work_dir
2056            .path()
2057            .join("workspace/tasks/ST-proj-4/ctx/_ctx.md");
2058        assert!(
2059            !unexpected_file.exists(),
2060            "declared root_preference=ProjectRoot must not fall back to work_dir: {unexpected_file:?}"
2061        );
2062    }
2063}