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            "implementer",
777            "task-x",
778            "code-worker",
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            "implementer",
793            "task-x",
794            "code-worker",
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/<variant>.md`) are a separate case and are
823        // allowed. This one forbidden pattern is assembled by string
824        // concat rather than written literally, so that a scan for it does
825        // not match its own assertion — do not "simplify" it back into a
826        // literal. (Unrelated: the wrapper-path assertion further down
827        // does carry a literal, because `.claude/agents/<variant>.md` is
828        // the documented Claude Code wrapper convention this server
829        // renders — see `DEFAULT_WRAPPER_DIR` — not a leaked path.)
830        let forbidden_doc_ref = format!(".{}/CLAUDE.md", "claude");
831        assert!(
832            !d.contains(&forbidden_doc_ref),
833            "directive must not reference {forbidden_doc_ref} (out of MainAI scope): {d}"
834        );
835    }
836
837    #[test]
838    fn directive_omits_data_endpoint_when_none() {
839        let d = default_spawn_directive(
840            "implementer",
841            "task-x",
842            "code-worker",
843            &view_with(None, None, None),
844            None,
845            None,
846            None,
847        );
848        assert!(!d.contains("[Data path endpoint"));
849        assert!(!d.contains("DATA_EMIT"));
850        assert!(!d.contains("DATA_GET"));
851    }
852
853    #[test]
854    fn directive_emits_data_endpoint_when_some() {
855        let base = "http://127.0.0.1:7785";
856        let d = default_spawn_directive(
857            "implementer",
858            "task-x",
859            "code-worker",
860            &view_with(None, None, None),
861            Some(base),
862            None,
863            None,
864        );
865        assert!(
866            d.contains("[Data path endpoint"),
867            "directive missing data endpoint block header: {d}"
868        );
869        assert!(
870            d.contains(&format!("DATA_EMIT: {base}/v1/data/emit")),
871            "directive missing single-mouth emit line: {d}"
872        );
873        assert!(
874            d.contains("Bearer worker_handle or ?token="),
875            "directive missing auth transport hint: {d}"
876        );
877        assert!(
878            d.contains(&format!("DATA_GET:  {base}/v1/data/<out_id|out_name>")),
879            "directive missing GET line: {d}"
880        );
881        assert!(
882            !d.contains("emit-auth"),
883            "old split endpoint must not leak into directive: {d}"
884        );
885        assert!(
886            d.contains("bypassing the MainAgent") && d.contains("out_id ref"),
887            "directive should carry the ownership + bypass reasoning: {d}"
888        );
889    }
890
891    #[test]
892    fn directive_carries_declared_subagent_type_and_has_no_fallback() {
893        let d = default_spawn_directive(
894            "implementer",
895            "task-x",
896            "code-worker",
897            &view_with(None, None, None),
898            None,
899            None,
900            None,
901        );
902        assert!(
903            d.contains("subagent_type=\"code-worker\""),
904            "directive must carry the Blueprint-declared subagent_type literally: {d}"
905        );
906        assert!(
907            d.contains(".claude/agents/code-worker.md"),
908            "directive must reference the declared subagent's own .md path: {d}"
909        );
910        // The old hardcoded default and its silent-fallback text must be gone.
911        assert!(
912            !d.contains("general-purpose"),
913            "directive must not fall back to subagent_type=\"general-purpose\": {d}"
914        );
915        assert!(
916            !d.contains("mse-worker\""),
917            "directive must not carry the old hardcoded \"mse-worker\" literal: {d}"
918        );
919        assert!(
920            d.contains("FAIL LOUD"),
921            "directive must instruct the MainAI to fail loud instead of falling back: {d}"
922        );
923    }
924
925    // ─── Issue #8: base_url rendering + fallback framing ─────────────────
926
927    /// Layer 1: when `base_url` is `Some`, it must land verbatim in the
928    /// SubAgent-prompt block, so the operator can copy the frame
929    /// through without a `mse_doctor` lookup.
930    #[test]
931    fn directive_renders_actual_base_url_when_some() {
932        let d = default_spawn_directive(
933            "implementer",
934            "task-x",
935            "code-worker",
936            &view_with(None, None, None),
937            None,
938            Some("http://127.0.0.1:8888"),
939            None,
940        );
941        assert!(
942            d.contains("base_url: http://127.0.0.1:8888"),
943            "directive must render the actual bind literally: {d}"
944        );
945        assert!(
946            !d.contains("mse_doctor"),
947            "no mse_doctor detour when bind is known: {d}"
948        );
949    }
950
951    /// Layer 3: when `base_url` is `None` (unit tests, mock harnesses,
952    /// pre-serve rendering) the fallback line must point the reader at
953    /// `mse_doctor` — never a fake port number.
954    #[test]
955    fn directive_falls_back_to_mse_doctor_pointer_when_none() {
956        let d = default_spawn_directive(
957            "implementer",
958            "task-x",
959            "code-worker",
960            &view_with(None, None, None),
961            None,
962            None,
963            None,
964        );
965        assert!(
966            d.contains("check with mse_doctor"),
967            "fallback must point at mse_doctor: {d}"
968        );
969    }
970
971    /// Regression guard: the historical `7786` example port (the whole
972    /// origin of issue #8) must not survive in the rendered directive
973    /// under any input combination.
974    #[test]
975    fn directive_never_contains_stale_example_port_7786() {
976        for base in [
977            None,
978            Some("http://127.0.0.1:7777"),
979            Some("http://192.0.2.1:9000"),
980        ] {
981            let d = default_spawn_directive(
982                "implementer",
983                "task-x",
984                "code-worker",
985                &view_with(Some("mse-task-alias"), None, None),
986                Some("http://127.0.0.1:7785"),
987                base,
988                None,
989            );
990            assert!(
991                !d.contains("7786"),
992                "stale example port 7786 leaked: base={base:?}, d={d}"
993            );
994        }
995    }
996
997    // ─── Issue #13: run_id observation route (doc-drift fix) ─────────────
998
999    /// Regression guard: the stale `GET /v1/tasks/{id}` observation hint
1000    /// (a route that never returns a single `RunRecord`) must be gone —
1001    /// the directive must point at the real drill-down route instead.
1002    #[test]
1003    fn directive_never_contains_stale_tasks_id_route() {
1004        let d = default_spawn_directive(
1005            "implementer",
1006            "task-x",
1007            "code-worker",
1008            &view_with(None, None, None),
1009            None,
1010            None,
1011            Some("R-abc123"),
1012        );
1013        assert!(
1014            !d.contains("/v1/tasks/{id}") && !d.contains("/v1/tasks/{{id}}"),
1015            "stale /v1/tasks/{{id}} observation hint leaked: {d}"
1016        );
1017    }
1018
1019    /// When `run_id` is `Some`, it is rendered literally into the
1020    /// observation route hint (`GET /v1/runs/<run_id>`).
1021    #[test]
1022    fn directive_renders_actual_run_id_when_some() {
1023        let d = default_spawn_directive(
1024            "implementer",
1025            "task-x",
1026            "code-worker",
1027            &view_with(None, None, None),
1028            None,
1029            None,
1030            Some("R-abc123"),
1031        );
1032        assert!(
1033            d.contains("GET <base_url>/v1/runs/R-abc123"),
1034            "directive missing real run_id in observation route: {d}"
1035        );
1036    }
1037
1038    /// `run_id: None` (no run tracing for this launch) falls back to a
1039    /// generic placeholder route rather than a stale/incorrect one.
1040    #[test]
1041    fn directive_falls_back_to_run_id_placeholder_when_none() {
1042        let d = default_spawn_directive(
1043            "implementer",
1044            "task-x",
1045            "code-worker",
1046            &view_with(None, None, None),
1047            None,
1048            None,
1049            None,
1050        );
1051        assert!(
1052            d.contains("GET <base_url>/v1/runs/<run_id>"),
1053            "directive missing placeholder observation route: {d}"
1054        );
1055    }
1056
1057    // ─── Issue #17: project_root / work_dir header lines ─────────────────
1058
1059    /// Both absent → neither header line appears (no empty-string
1060    /// placeholder either).
1061    #[test]
1062    fn directive_omits_project_root_and_work_dir_when_both_none() {
1063        let d = default_spawn_directive(
1064            "implementer",
1065            "task-x",
1066            "code-worker",
1067            &view_with(None, None, None),
1068            None,
1069            None,
1070            None,
1071        );
1072        assert!(!d.contains("project_root:"));
1073        assert!(!d.contains("work_dir:"));
1074    }
1075
1076    /// Both present → both header lines render literally, alongside
1077    /// `project_name_alias`'s existing splice.
1078    #[test]
1079    fn directive_splices_project_root_and_work_dir_when_both_present() {
1080        let d = default_spawn_directive(
1081            "implementer",
1082            "task-x",
1083            "code-worker",
1084            &view_with(None, Some("/repo"), Some("/repo/work")),
1085            None,
1086            None,
1087            None,
1088        );
1089        assert!(
1090            d.contains("project_root: /repo"),
1091            "directive missing project_root header: {d}"
1092        );
1093        assert!(
1094            d.contains("work_dir: /repo/work"),
1095            "directive missing work_dir header: {d}"
1096        );
1097    }
1098
1099    /// Partial: `project_root` present, `work_dir` absent — each field is
1100    /// independent, so only the present one renders.
1101    #[test]
1102    fn directive_splices_project_root_only_when_work_dir_absent() {
1103        let d = default_spawn_directive(
1104            "implementer",
1105            "task-x",
1106            "code-worker",
1107            &view_with(None, Some("/repo"), None),
1108            None,
1109            None,
1110            None,
1111        );
1112        assert!(
1113            d.contains("project_root: /repo"),
1114            "directive missing project_root header: {d}"
1115        );
1116        assert!(!d.contains("work_dir:"));
1117    }
1118
1119    // ─── GH #20: task_metadata header line (Contract C, closes the F2 gap) ─
1120
1121    /// `task_metadata` renders as a new `task_metadata: {compact-json}`
1122    /// line — the F2 gap the `operator-execution-model` guide tracked
1123    /// (`task_metadata`'s inner keys were never spliced into the
1124    /// directive before GH #20).
1125    #[test]
1126    fn directive_splices_task_metadata_when_some() {
1127        let view = AgentContextView {
1128            task_metadata: Some(serde_json::json!({"issue": 20})),
1129            ..view_with(None, Some("/repo"), None)
1130        };
1131        let d = default_spawn_directive(
1132            "implementer",
1133            "task-x",
1134            "code-worker",
1135            &view,
1136            None,
1137            None,
1138            None,
1139        );
1140        assert!(
1141            d.contains(r#"task_metadata: {"issue":20}"#),
1142            "directive missing task_metadata header: {d}"
1143        );
1144        // Additive-only: the pre-existing project_root line still renders.
1145        assert!(d.contains("project_root: /repo"));
1146    }
1147
1148    /// `task_metadata: None` (absent) omits the line entirely — no
1149    /// empty-string placeholder, matching every other header line's
1150    /// absent-field contract.
1151    #[test]
1152    fn directive_omits_task_metadata_when_none() {
1153        let d = default_spawn_directive(
1154            "implementer",
1155            "task-x",
1156            "code-worker",
1157            &view_with(None, None, None),
1158            None,
1159            None,
1160            None,
1161        );
1162        assert!(!d.contains("task_metadata:"));
1163    }
1164
1165    // ─── Issue #7: spawn_halt handling in Operator::execute ──────────────
1166
1167    fn test_ctx(task_id: &str) -> mlua_swarm::Ctx {
1168        mlua_swarm::Ctx::new(mlua_swarm::StepId::parse(task_id).unwrap(), 1, "a")
1169    }
1170
1171    fn test_worker_binding() -> mlua_swarm::WorkerBinding {
1172        mlua_swarm::WorkerBinding {
1173            variant: "test-variant".into(),
1174            tools: vec![],
1175            request_digest: None,
1176            requested_model: None,
1177        }
1178    }
1179
1180    fn test_cap_token() -> mlua_swarm::CapToken {
1181        mlua_swarm::CapToken {
1182            agent_id: "a".into(),
1183            role: mlua_swarm::Role::Worker,
1184            scopes: vec!["*".into()],
1185            issued_at: 0,
1186            expire_at: u64::MAX / 2,
1187            max_uses: None,
1188            nonce: "test-nonce".into(),
1189            sig_hex: "".into(),
1190        }
1191    }
1192
1193    /// A `PendingReply::SpawnHalt` reply must translate into a
1194    /// `Ok(WorkerResult { ok: true, value: <halt marker> })` — a normal
1195    /// termination, not a `WorkerError::Failed` (fail-loud). This is
1196    /// the whole point of the new verb: distinguishing a controlled
1197    /// halt from a real worker error at the log / retry-signal level.
1198    #[tokio::test]
1199    async fn spawn_halt_reply_lands_as_ok_worker_result_with_marker() {
1200        use mlua_swarm::Operator;
1201        use tokio::sync::mpsc;
1202
1203        let (tx, mut rx) = mpsc::unbounded_channel();
1204        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1205            SessionId::parse("S-halt").unwrap(),
1206            tx,
1207            None,
1208        ));
1209
1210        // Kick execute() in a background task so we can grab the
1211        // req_id the server assigns and inject a matching SpawnHalt.
1212        let session_bg = session.clone();
1213        let handle = tokio::spawn(async move {
1214            session_bg
1215                .execute(
1216                    &test_ctx("ST-halt"),
1217                    None,
1218                    "".into(),
1219                    Some(test_worker_binding()),
1220                    test_cap_token(),
1221                )
1222                .await
1223        });
1224
1225        let sent = rx.recv().await.expect("Spawn sent");
1226        let req_id = match sent {
1227            ServerMsg::Spawn { req_id, .. } => req_id,
1228            other => panic!("expected Spawn, got {other:?}"),
1229        };
1230
1231        session
1232            .resolve_pending(
1233                &req_id,
1234                PendingReply::SpawnHalt {
1235                    value: serde_json::json!({"partial": "abc"}),
1236                    reason: Some("shape verified".into()),
1237                },
1238            )
1239            .await;
1240
1241        let result = handle.await.expect("join").expect("execute Ok");
1242        assert!(
1243            result.ok,
1244            "spawn_halt must land as ok=true (normal termination), got: {result:?}"
1245        );
1246        assert_eq!(result.value["halted"], true);
1247        assert_eq!(result.value["reason"], "shape verified");
1248        assert_eq!(result.value["value"], serde_json::json!({"partial": "abc"}));
1249    }
1250
1251    /// `spawn_ack { ok: false, error: Some(_) }` must retain its
1252    /// current fail-loud behaviour (backward compat guard).
1253    #[tokio::test]
1254    async fn spawn_ack_with_error_still_lands_as_worker_error() {
1255        use mlua_swarm::{Operator, WorkerError};
1256        use tokio::sync::mpsc;
1257
1258        let (tx, mut rx) = mpsc::unbounded_channel();
1259        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1260            SessionId::parse("S-err").unwrap(),
1261            tx,
1262            None,
1263        ));
1264
1265        let session_bg = session.clone();
1266        let handle = tokio::spawn(async move {
1267            session_bg
1268                .execute(
1269                    &test_ctx("ST-err"),
1270                    None,
1271                    "".into(),
1272                    Some(test_worker_binding()),
1273                    test_cap_token(),
1274                )
1275                .await
1276        });
1277
1278        let sent = rx.recv().await.expect("Spawn sent");
1279        let req_id = match sent {
1280            ServerMsg::Spawn { req_id, .. } => req_id,
1281            other => panic!("expected Spawn, got {other:?}"),
1282        };
1283
1284        session
1285            .resolve_pending(
1286                &req_id,
1287                PendingReply::SpawnAck {
1288                    value: serde_json::json!({}),
1289                    ok: false,
1290                    error: Some("real crash".into()),
1291                    stats: None,
1292                },
1293            )
1294            .await;
1295
1296        let err = handle.await.expect("join").expect_err("must be error");
1297        assert!(matches!(err, WorkerError::Failed(msg) if msg.contains("real crash")));
1298    }
1299
1300    /// B-2: a spawn parked in `execute` (awaiting a `SpawnAck` that never
1301    /// arrives) must unblock with a `WorkerError::Failed` as soon as
1302    /// `fail_pending` drains the pending map — the teardown path's
1303    /// immediate-fail guarantee, so a torn-down session does not leave a
1304    /// spawn orphaned until the run's sync timeout fires.
1305    #[tokio::test]
1306    async fn fail_pending_unblocks_a_parked_spawn_with_worker_error() {
1307        use mlua_swarm::{Operator, WorkerError};
1308        use tokio::sync::mpsc;
1309
1310        let (tx, mut rx) = mpsc::unbounded_channel();
1311        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1312            SessionId::parse("S-teardown").unwrap(),
1313            tx,
1314            None,
1315        ));
1316
1317        let session_bg = session.clone();
1318        let handle = tokio::spawn(async move {
1319            session_bg
1320                .execute(
1321                    &test_ctx("ST-teardown"),
1322                    None,
1323                    "".into(),
1324                    Some(test_worker_binding()),
1325                    test_cap_token(),
1326                )
1327                .await
1328        });
1329
1330        // Wait until the Spawn is actually parked (its pending entry is
1331        // registered) before tearing down, so the drain has something to
1332        // fail rather than racing the insert.
1333        let _sent = rx.recv().await.expect("Spawn sent");
1334
1335        session.fail_pending("operator session torn down").await;
1336
1337        let err = handle
1338            .await
1339            .expect("join")
1340            .expect_err("a parked spawn must fail once pending is drained");
1341        assert!(
1342            matches!(err, WorkerError::Failed(_)),
1343            "fail_pending must surface a WorkerError::Failed, got: {err:?}"
1344        );
1345    }
1346
1347    // ─── Issue #17: end-to-end `execute()` splice (ctx.meta.runtime → Spawn.directive) ───
1348
1349    /// `Ctx.meta.runtime` carrying both `project_root` and `work_dir`
1350    /// (the `TaskInputMiddleware` injection shape) must land in the
1351    /// `ServerMsg::Spawn.directive` actually sent over the wire — not
1352    /// just in the pure `default_spawn_directive` helper.
1353    #[tokio::test]
1354    async fn execute_splices_project_root_and_work_dir_from_ctx_meta_runtime() {
1355        use mlua_swarm::Operator;
1356        use tokio::sync::mpsc;
1357
1358        let (tx, mut rx) = mpsc::unbounded_channel();
1359        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1360            SessionId::parse("S-ctxroot").unwrap(),
1361            tx,
1362            None,
1363        ));
1364
1365        let mut ctx = test_ctx("ST-ctxroot");
1366        ctx.meta.runtime.insert(
1367            TASK_PROJECT_ROOT_KEY.to_string(),
1368            serde_json::json!("/repo"),
1369        );
1370        ctx.meta.runtime.insert(
1371            TASK_WORK_DIR_KEY.to_string(),
1372            serde_json::json!("/repo/work"),
1373        );
1374
1375        let session_bg = session.clone();
1376        let handle = tokio::spawn(async move {
1377            session_bg
1378                .execute(
1379                    &ctx,
1380                    None,
1381                    "".into(),
1382                    Some(test_worker_binding()),
1383                    test_cap_token(),
1384                )
1385                .await
1386        });
1387
1388        let sent = rx.recv().await.expect("Spawn sent");
1389        let req_id = match sent {
1390            ServerMsg::Spawn {
1391                req_id, directive, ..
1392            } => {
1393                // issue #18: `Spawn.directive` is now `Value`; extract the
1394                // `String` it wraps (always a `Value::String` on this
1395                // path — see `default_spawn_directive_with_task_directive`).
1396                let directive = directive.as_str();
1397                assert!(
1398                    directive.contains("project_root: /repo"),
1399                    "directive missing project_root splice: {directive}"
1400                );
1401                assert!(
1402                    directive.contains("work_dir: /repo/work"),
1403                    "directive missing work_dir splice: {directive}"
1404                );
1405                req_id
1406            }
1407            other => panic!("expected Spawn, got {other:?}"),
1408        };
1409
1410        session
1411            .resolve_pending(
1412                &req_id,
1413                PendingReply::SpawnAck {
1414                    value: serde_json::json!({}),
1415                    ok: true,
1416                    error: None,
1417                    stats: None,
1418                },
1419            )
1420            .await;
1421        handle.await.expect("join").expect("execute Ok");
1422    }
1423
1424    /// Partial: only `project_root` present in `ctx.meta.runtime` (no
1425    /// `TaskInputMiddleware`-populated `work_dir`) — the splice is
1426    /// per-field independent, matching `TaskInputMiddleware`'s own
1427    /// per-field-optional contract.
1428    #[tokio::test]
1429    async fn execute_splices_project_root_only_when_ctx_meta_runtime_partial() {
1430        use mlua_swarm::Operator;
1431        use tokio::sync::mpsc;
1432
1433        let (tx, mut rx) = mpsc::unbounded_channel();
1434        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1435            SessionId::parse("S-ctxpartial").unwrap(),
1436            tx,
1437            None,
1438        ));
1439
1440        let mut ctx = test_ctx("ST-ctxpartial");
1441        ctx.meta.runtime.insert(
1442            TASK_PROJECT_ROOT_KEY.to_string(),
1443            serde_json::json!("/repo"),
1444        );
1445
1446        let session_bg = session.clone();
1447        let handle = tokio::spawn(async move {
1448            session_bg
1449                .execute(
1450                    &ctx,
1451                    None,
1452                    "".into(),
1453                    Some(test_worker_binding()),
1454                    test_cap_token(),
1455                )
1456                .await
1457        });
1458
1459        let sent = rx.recv().await.expect("Spawn sent");
1460        let req_id = match sent {
1461            ServerMsg::Spawn {
1462                req_id, directive, ..
1463            } => {
1464                let directive = directive.as_str();
1465                assert!(
1466                    directive.contains("project_root: /repo"),
1467                    "directive missing project_root splice: {directive}"
1468                );
1469                assert!(!directive.contains("work_dir:"));
1470                req_id
1471            }
1472            other => panic!("expected Spawn, got {other:?}"),
1473        };
1474
1475        session
1476            .resolve_pending(
1477                &req_id,
1478                PendingReply::SpawnAck {
1479                    value: serde_json::json!({}),
1480                    ok: true,
1481                    error: None,
1482                    stats: None,
1483                },
1484            )
1485            .await;
1486        handle.await.expect("join").expect("execute Ok");
1487    }
1488
1489    /// Neither present in `ctx.meta.runtime` (no `TaskInputMiddleware`
1490    /// layered for this launch) — the directive carries neither header
1491    /// line, matching pre-issue-#17 behavior exactly.
1492    #[tokio::test]
1493    async fn execute_omits_project_root_and_work_dir_when_ctx_meta_runtime_absent() {
1494        use mlua_swarm::Operator;
1495        use tokio::sync::mpsc;
1496
1497        let (tx, mut rx) = mpsc::unbounded_channel();
1498        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1499            SessionId::parse("S-ctxabsent").unwrap(),
1500            tx,
1501            None,
1502        ));
1503
1504        let ctx = test_ctx("ST-ctxabsent");
1505
1506        let session_bg = session.clone();
1507        let handle = tokio::spawn(async move {
1508            session_bg
1509                .execute(
1510                    &ctx,
1511                    None,
1512                    "".into(),
1513                    Some(test_worker_binding()),
1514                    test_cap_token(),
1515                )
1516                .await
1517        });
1518
1519        let sent = rx.recv().await.expect("Spawn sent");
1520        let req_id = match sent {
1521            ServerMsg::Spawn {
1522                req_id, directive, ..
1523            } => {
1524                let directive = directive.as_str();
1525                assert!(!directive.contains("project_root:"));
1526                assert!(!directive.contains("work_dir:"));
1527                req_id
1528            }
1529            other => panic!("expected Spawn, got {other:?}"),
1530        };
1531
1532        session
1533            .resolve_pending(
1534                &req_id,
1535                PendingReply::SpawnAck {
1536                    value: serde_json::json!({}),
1537                    ok: true,
1538                    error: None,
1539                    stats: None,
1540                },
1541            )
1542            .await;
1543        handle.await.expect("join").expect("execute Ok");
1544    }
1545
1546    /// GH #20 / F2 gap: `task_metadata` in `ctx.meta.runtime` (the
1547    /// `TaskInputMiddleware` injection shape) now reaches the
1548    /// `ServerMsg::Spawn.directive` actually sent over the wire, via
1549    /// `AgentContextView::materialized_or_from_ctx` falling back to
1550    /// `from_ctx` when `AgentContextMiddleware` was not layered.
1551    #[tokio::test]
1552    async fn execute_splices_task_metadata_from_ctx_meta_runtime() {
1553        use mlua_swarm::Operator;
1554        use tokio::sync::mpsc;
1555
1556        let (tx, mut rx) = mpsc::unbounded_channel();
1557        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1558            SessionId::parse("S-ctxmeta").unwrap(),
1559            tx,
1560            None,
1561        ));
1562
1563        let mut ctx = test_ctx("ST-ctxmeta");
1564        ctx.meta.runtime.insert(
1565            TASK_METADATA_KEY.to_string(),
1566            serde_json::json!({"issue": 20}),
1567        );
1568
1569        let session_bg = session.clone();
1570        let handle = tokio::spawn(async move {
1571            session_bg
1572                .execute(
1573                    &ctx,
1574                    None,
1575                    "".into(),
1576                    Some(test_worker_binding()),
1577                    test_cap_token(),
1578                )
1579                .await
1580        });
1581
1582        let sent = rx.recv().await.expect("Spawn sent");
1583        let req_id = match sent {
1584            ServerMsg::Spawn {
1585                req_id, directive, ..
1586            } => {
1587                let directive = directive.as_str();
1588                assert!(
1589                    directive.contains(r#"task_metadata: {"issue":20}"#),
1590                    "directive missing task_metadata splice: {directive}"
1591                );
1592                req_id
1593            }
1594            other => panic!("expected Spawn, got {other:?}"),
1595        };
1596
1597        session
1598            .resolve_pending(
1599                &req_id,
1600                PendingReply::SpawnAck {
1601                    value: serde_json::json!({}),
1602                    ok: true,
1603                    error: None,
1604                    stats: None,
1605                },
1606            )
1607            .await;
1608        handle.await.expect("join").expect("execute Ok");
1609    }
1610
1611    // ─── Issue #18: `Value` pass-through render boundary
1612    //     (`default_spawn_directive_with_task_directive`) ───
1613
1614    /// A `String` seed splices in verbatim, unquoted (matching
1615    /// `Value::String(s) => s.clone()` — no JSON-quoting artifact).
1616    #[test]
1617    fn with_task_directive_splices_string_seed_verbatim() {
1618        let directive = default_spawn_directive_with_task_directive(
1619            "implementer",
1620            "task-x",
1621            "code-worker",
1622            &view_with(None, None, None),
1623            None,
1624            None,
1625            None,
1626            &serde_json::json!("do the thing"),
1627        );
1628        let text = directive.as_str();
1629        assert!(
1630            text.contains("task_directive: do the thing"),
1631            "missing task_directive line for a String seed: {text}"
1632        );
1633    }
1634
1635    /// An Object seed renders as its JSON literal (issue #18 Invariant 3 —
1636    /// same shape `Engine::start_task` / `Engine::dispatch_attempt_with`
1637    /// produce for the Worker HTTP path via `render_directive_to_string`).
1638    #[test]
1639    fn with_task_directive_renders_object_seed_as_json_literal() {
1640        let directive = default_spawn_directive_with_task_directive(
1641            "implementer",
1642            "task-x",
1643            "code-worker",
1644            &view_with(None, None, None),
1645            None,
1646            None,
1647            None,
1648            &serde_json::json!({"key": "value"}),
1649        );
1650        let text = directive.as_str();
1651        assert!(
1652            text.contains(r#"task_directive: {"key":"value"}"#),
1653            "missing JSON-literal task_directive line for an Object seed: {text}"
1654        );
1655    }
1656
1657    /// `Value::Null` (no seed recovered) omits the line entirely — the
1658    /// output is byte-identical to `default_spawn_directive`'s own text,
1659    /// preserving every pre-issue-#18 caller unchanged.
1660    #[test]
1661    fn with_task_directive_omits_line_when_null() {
1662        let wrapped = default_spawn_directive_with_task_directive(
1663            "implementer",
1664            "task-x",
1665            "code-worker",
1666            &view_with(None, None, None),
1667            None,
1668            None,
1669            None,
1670            &serde_json::Value::Null,
1671        );
1672        let plain = default_spawn_directive(
1673            "implementer",
1674            "task-x",
1675            "code-worker",
1676            &view_with(None, None, None),
1677            None,
1678            None,
1679            None,
1680        );
1681        assert_eq!(
1682            wrapped,
1683            serde_json::Value::String(plain),
1684            "Value::Null seed must not add a task_directive line"
1685        );
1686    }
1687
1688    /// End-to-end via `execute()`: an Object-shaped `Step.in` seed, once
1689    /// rendered to a JSON-literal `String` by the engine (the Worker HTTP
1690    /// path's `render_directive_to_string`), reaches `ServerMsg::Spawn`
1691    /// with the same JSON literal spliced into `directive` — the WS
1692    /// render layer is the sole `Value → String` coercion point on this
1693    /// path (issue #18).
1694    #[tokio::test]
1695    async fn execute_splices_json_literal_task_directive_for_object_seed() {
1696        use mlua_swarm::Operator;
1697        use tokio::sync::mpsc;
1698
1699        let (tx, mut rx) = mpsc::unbounded_channel();
1700        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1701            SessionId::parse("S-objseed").unwrap(),
1702            tx,
1703            None,
1704        ));
1705
1706        let ctx = test_ctx("ST-objseed");
1707        // Issue #18: `Value` flows end-to-end from `Step.in` through the
1708        // engine, so the Object seed reaches `execute()` as `Value` — no
1709        // stringification upstream. Only the WS Spawn frame render
1710        // performs the `Value → String` coercion.
1711        let rendered_prompt = serde_json::json!({"key": "value"});
1712
1713        let session_bg = session.clone();
1714        let handle = tokio::spawn(async move {
1715            session_bg
1716                .execute(
1717                    &ctx,
1718                    None,
1719                    rendered_prompt,
1720                    Some(test_worker_binding()),
1721                    test_cap_token(),
1722                )
1723                .await
1724        });
1725
1726        let sent = rx.recv().await.expect("Spawn sent");
1727        let req_id = match sent {
1728            ServerMsg::Spawn {
1729                req_id, directive, ..
1730            } => {
1731                let directive = directive.as_str();
1732                assert!(
1733                    directive.contains(r#"task_directive: {"key":"value"}"#),
1734                    "directive missing JSON-literal task_directive splice: {directive}"
1735                );
1736                req_id
1737            }
1738            other => panic!("expected Spawn, got {other:?}"),
1739        };
1740
1741        session
1742            .resolve_pending(
1743                &req_id,
1744                PendingReply::SpawnAck {
1745                    value: serde_json::json!({}),
1746                    ok: true,
1747                    error: None,
1748                    stats: None,
1749                },
1750            )
1751            .await;
1752        handle.await.expect("join").expect("execute Ok");
1753    }
1754
1755    // ─── issue #21/ST2: in-flight projection hook (`append_projection_pointer`) ───
1756
1757    /// `view.work_dir` present → the spawn directive carries a
1758    /// `ctx_projection:` pointer line, and the pointed-at file actually
1759    /// exists on disk (subtask-2 Tests #3).
1760    #[tokio::test]
1761    async fn execute_with_work_dir_appends_ctx_projection_pointer_and_materializes_file() {
1762        use mlua_swarm::Operator;
1763        use tokio::sync::mpsc;
1764
1765        let dir = tempfile::TempDir::new().unwrap();
1766        let mut ctx = test_ctx("ST-proj-1");
1767        ctx.meta.runtime.insert(
1768            TASK_WORK_DIR_KEY.to_string(),
1769            Value::String(dir.path().to_string_lossy().into_owned()),
1770        );
1771
1772        let (tx, mut rx) = mpsc::unbounded_channel();
1773        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1774            SessionId::parse("S-proj-1").unwrap(),
1775            tx,
1776            None,
1777        ));
1778
1779        let session_bg = session.clone();
1780        let handle = tokio::spawn(async move {
1781            session_bg
1782                .execute(
1783                    &ctx,
1784                    None,
1785                    "".into(),
1786                    Some(test_worker_binding()),
1787                    test_cap_token(),
1788                )
1789                .await
1790        });
1791
1792        let sent = rx.recv().await.expect("Spawn sent");
1793        let req_id = match sent {
1794            ServerMsg::Spawn {
1795                req_id, directive, ..
1796            } => {
1797                assert!(
1798                    directive.contains("ctx_projection:"),
1799                    "directive missing ctx_projection pointer line: {directive}"
1800                );
1801                // ST5 (`projection-adapter`) removal confirmation: the
1802                // pre-ST5 `ctx_step_dir:` companion line (pointing a
1803                // worker at the raw materialize directory + the retired
1804                // `mse_ctx_get` MCP tool) must never reappear — the
1805                // Worker axis now gets prior steps' OUTPUT pointers
1806                // automatically via `context.steps`.
1807                assert!(
1808                    !directive.contains("ctx_step_dir:"),
1809                    "directive must not carry the retired ctx_step_dir line: {directive}"
1810                );
1811                req_id
1812            }
1813            other => panic!("expected Spawn, got {other:?}"),
1814        };
1815
1816        session
1817            .resolve_pending(
1818                &req_id,
1819                PendingReply::SpawnAck {
1820                    value: serde_json::json!({}),
1821                    ok: true,
1822                    error: None,
1823                    stats: None,
1824                },
1825            )
1826            .await;
1827        handle.await.expect("join").expect("execute Ok");
1828
1829        let expected_file = dir.path().join("workspace/tasks/ST-proj-1/ctx/_ctx.md");
1830        assert!(
1831            expected_file.exists(),
1832            "materialized projection file missing at {expected_file:?}"
1833        );
1834    }
1835
1836    /// `view.work_dir` absent → the spawn directive carries no
1837    /// `ctx_projection:` line, and the spawn still succeeds (non-fatal
1838    /// fallback, subtask-2 Tests #4 + Invariant "must never turn a
1839    /// would-have-succeeded spawn into a failure").
1840    #[tokio::test]
1841    async fn execute_without_work_dir_spawns_without_ctx_projection_pointer() {
1842        use mlua_swarm::Operator;
1843        use tokio::sync::mpsc;
1844
1845        let (tx, mut rx) = mpsc::unbounded_channel();
1846        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1847            SessionId::parse("S-proj-2").unwrap(),
1848            tx,
1849            None,
1850        ));
1851
1852        let session_bg = session.clone();
1853        let handle = tokio::spawn(async move {
1854            session_bg
1855                .execute(
1856                    &test_ctx("ST-proj-2"),
1857                    None,
1858                    "".into(),
1859                    Some(test_worker_binding()),
1860                    test_cap_token(),
1861                )
1862                .await
1863        });
1864
1865        let sent = rx.recv().await.expect("Spawn sent");
1866        let req_id = match sent {
1867            ServerMsg::Spawn {
1868                req_id, directive, ..
1869            } => {
1870                assert!(
1871                    !directive.contains("ctx_projection:"),
1872                    "directive must not carry a pointer line when work_dir is absent \
1873                     (fallback): {directive}"
1874                );
1875                req_id
1876            }
1877            other => panic!("expected Spawn, got {other:?}"),
1878        };
1879
1880        session
1881            .resolve_pending(
1882                &req_id,
1883                PendingReply::SpawnAck {
1884                    value: serde_json::json!({}),
1885                    ok: true,
1886                    error: None,
1887                    stats: None,
1888                },
1889            )
1890            .await;
1891        handle
1892            .await
1893            .expect("join")
1894            .expect("execute Ok — a materialize skip must not fail the spawn");
1895    }
1896
1897    // ──────────────────────────────────────────────────────────────
1898    // GH #27 (follow-up to #23): ProjectionPlacement resolver wiring
1899    // ──────────────────────────────────────────────────────────────
1900
1901    /// `view.work_dir` ABSENT but `view.project_root` present, with the
1902    /// byte-compat default `ProjectionPlacement` (`root_preference =
1903    /// WorkDir`, falling back to `project_root`) — the asymmetry fix: a
1904    /// pre-GH-#27 build would have skipped the pointer entirely here
1905    /// (`view.work_dir` ONLY, no fallback); this build now falls back the
1906    /// SAME way the submit-time sink and server read-back always did.
1907    #[tokio::test]
1908    async fn execute_with_project_root_only_appends_ctx_projection_pointer_default_placement() {
1909        use mlua_swarm::Operator;
1910        use tokio::sync::mpsc;
1911
1912        let dir = tempfile::TempDir::new().unwrap();
1913        let mut ctx = test_ctx("ST-proj-3");
1914        ctx.meta.runtime.insert(
1915            TASK_PROJECT_ROOT_KEY.to_string(),
1916            Value::String(dir.path().to_string_lossy().into_owned()),
1917        );
1918
1919        let (tx, mut rx) = mpsc::unbounded_channel();
1920        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1921            SessionId::parse("S-proj-3").unwrap(),
1922            tx,
1923            None,
1924        ));
1925
1926        let session_bg = session.clone();
1927        let handle = tokio::spawn(async move {
1928            session_bg
1929                .execute(
1930                    &ctx,
1931                    None,
1932                    "".into(),
1933                    Some(test_worker_binding()),
1934                    test_cap_token(),
1935                )
1936                .await
1937        });
1938
1939        let sent = rx.recv().await.expect("Spawn sent");
1940        let req_id = match sent {
1941            ServerMsg::Spawn {
1942                req_id, directive, ..
1943            } => {
1944                assert!(
1945                    directive.contains("ctx_projection:"),
1946                    "work_dir absent must still fall back to project_root: {directive}"
1947                );
1948                req_id
1949            }
1950            other => panic!("expected Spawn, got {other:?}"),
1951        };
1952
1953        session
1954            .resolve_pending(
1955                &req_id,
1956                PendingReply::SpawnAck {
1957                    value: serde_json::json!({}),
1958                    ok: true,
1959                    error: None,
1960                    stats: None,
1961                },
1962            )
1963            .await;
1964        handle.await.expect("join").expect("execute Ok");
1965
1966        let expected_file = dir.path().join("workspace/tasks/ST-proj-3/ctx/_ctx.md");
1967        assert!(
1968            expected_file.exists(),
1969            "materialized projection file missing at {expected_file:?}"
1970        );
1971    }
1972
1973    /// A `ProjectionPlacement` stashed into
1974    /// `ctx.meta.runtime[PROJECTION_PLACEMENT_KEY]` (the same channel
1975    /// `AgentContextMiddleware` populates at spawn time) with
1976    /// `root_preference = ProjectRoot` and a custom `dir_template` changes
1977    /// BOTH which root is preferred (even though `work_dir` is ALSO
1978    /// present) AND the target directory layout the in-flight pointer
1979    /// materializes to.
1980    #[tokio::test]
1981    async fn execute_with_custom_projection_placement_uses_declared_root_and_template() {
1982        use mlua_swarm::core::projection_placement::{ProjectionPlacement, RootPreference};
1983        use mlua_swarm::Operator;
1984        use tokio::sync::mpsc;
1985
1986        let work_dir = tempfile::TempDir::new().unwrap();
1987        let project_root = tempfile::TempDir::new().unwrap();
1988        let mut ctx = test_ctx("ST-proj-4");
1989        ctx.meta.runtime.insert(
1990            TASK_WORK_DIR_KEY.to_string(),
1991            Value::String(work_dir.path().to_string_lossy().into_owned()),
1992        );
1993        ctx.meta.runtime.insert(
1994            TASK_PROJECT_ROOT_KEY.to_string(),
1995            Value::String(project_root.path().to_string_lossy().into_owned()),
1996        );
1997        let placement = ProjectionPlacement {
1998            root_preference: RootPreference::ProjectRoot,
1999            dir_template: "custom/{task_id}/out".to_string(),
2000        };
2001        ctx.meta.runtime.insert(
2002            PROJECTION_PLACEMENT_KEY.to_string(),
2003            serde_json::to_value(&placement).expect("placement serializes"),
2004        );
2005
2006        let (tx, mut rx) = mpsc::unbounded_channel();
2007        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
2008            SessionId::parse("S-proj-4").unwrap(),
2009            tx,
2010            None,
2011        ));
2012
2013        let session_bg = session.clone();
2014        let handle = tokio::spawn(async move {
2015            session_bg
2016                .execute(
2017                    &ctx,
2018                    None,
2019                    "".into(),
2020                    Some(test_worker_binding()),
2021                    test_cap_token(),
2022                )
2023                .await
2024        });
2025
2026        let sent = rx.recv().await.expect("Spawn sent");
2027        let req_id = match sent {
2028            ServerMsg::Spawn {
2029                req_id, directive, ..
2030            } => {
2031                assert!(
2032                    directive.contains("ctx_projection:"),
2033                    "directive missing ctx_projection pointer line: {directive}"
2034                );
2035                req_id
2036            }
2037            other => panic!("expected Spawn, got {other:?}"),
2038        };
2039
2040        session
2041            .resolve_pending(
2042                &req_id,
2043                PendingReply::SpawnAck {
2044                    value: serde_json::json!({}),
2045                    ok: true,
2046                    error: None,
2047                    stats: None,
2048                },
2049            )
2050            .await;
2051        handle.await.expect("join").expect("execute Ok");
2052
2053        let expected_file = project_root.path().join("custom/ST-proj-4/out/_ctx.md");
2054        assert!(
2055            expected_file.exists(),
2056            "materialized projection file missing at custom placement target {expected_file:?}"
2057        );
2058        let unexpected_file = work_dir
2059            .path()
2060            .join("workspace/tasks/ST-proj-4/ctx/_ctx.md");
2061        assert!(
2062            !unexpected_file.exists(),
2063            "declared root_preference=ProjectRoot must not fall back to work_dir: {unexpected_file:?}"
2064        );
2065    }
2066}