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