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