Skip to main content

mlua_swarm/worker/
process_spawner.rs

1//! `ProcessSpawner` — a general-purpose `SpawnerAdapter` implementation
2//! that spawns an arbitrary binary (or a one-line shell command) and
3//! runs it as a worker. The thin path for wrapping an agent-block CLI,
4//! an LLM CLI, a random binary, or a shell script as a worker.
5//!
6//! Direct library integration with the `agent-block-core` SDK lives on
7//! a separate axis, in
8//! [`crate::worker::agent_block::AgentBlockInProcessSpawnerFactory`]: the SDK
9//! is embedded in-process, and `bus.emit("worker_result", ...)` is
10//! captured host-side. This spawner's selling point is "call anything
11//! over a shell"; it is not agent-block-specific, and the two paths
12//! have fully separated responsibilities.
13//!
14//! Naming convention: `ProcessSpawner` starts a shell process, and
15//! `AgentBlockInProcessSpawnerFactory` provides direct integration
16//! with the agent-block SDK. Older commits still reference an
17//! "AgentBlockSpawner" — that was renamed to `ProcessSpawner` in the current design
18//! (commit 8d1058f). See mini-app issue `96821965` for the full
19//! rationale.
20//!
21//! # Modes (two flavours)
22//!
23//! **plain mode (default):**
24//! 1. On `spawn`, launch a child process with
25//!    `Command::new(self.program)` + `args`.
26//! 2. Write the directive to the child's stdin (used as the prompt).
27//! 3. Buffer the child's stdout in full.
28//! 4. Try to parse stdout as JSON; on failure wrap it as
29//!    `{"raw": "<text>"}`.
30//! 5. `ok = true` on exit code 0, otherwise `ok = false`.
31//! 6. Emit the `WorkerResult` in parallel via
32//!    `engine.submit_output(Final)` (design intent).
33//!
34//! **streaming mode (`.stream_mode(StreamMode::...)`):**
35//! 1-2. Same as plain mode.
36//! 3. Read the child's stdout **line by line** through a `BufReader`
37//!    for NDJSON — or via a different protocol later.
38//! 4. Parse each chunk as an `OutputEvent`; skip failures.
39//! 5. `engine.submit_output` each successfully-parsed event
40//!    **incrementally**.
41//! 6. When `OutputEvent::Final` arrives, fold its `{content, ok}`
42//!    into the `WorkerResult`.
43//! 7. If EOF is hit without a `Final`, mark the outcome `ok = false`
44//!    (Blocked).
45//!
46//! Only `StreamMode::NdjsonLines` ships today; SSE, length-prefixed,
47//! and friends are carries for future turns.
48//!
49//! Token metadata is also handed to the child as environment variables
50//! so a worker can re-pull if it needs to. `sig_hex` is deliberately
51//! not exported, to keep exposure minimal.
52
53use crate::core::agent_context::AgentContextView;
54use crate::core::ctx::Ctx;
55use crate::core::engine::Engine;
56use crate::types::{CapToken, StepId, WorkerId};
57use crate::worker::adapter::{SpawnError, SpawnerAdapter, WorkerError, WorkerResult};
58use crate::worker::output::{ContentRef, OutputEvent};
59use crate::worker::{Worker, WorkerJoinHandler};
60use async_trait::async_trait;
61use mlua_swarm_schema::SubprocessOutput;
62use serde_json::Value;
63use std::collections::BTreeMap;
64use std::process::Stdio;
65use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
66use tokio::process::Command;
67use tokio::sync::oneshot;
68use tokio_util::sync::CancellationToken;
69
70/// GH #83 — the closed, logic-free placeholder vocabulary a
71/// [`SubprocessDef`](mlua_swarm_schema::SubprocessDef) template may
72/// reference. Rendering is pure string substitution over exactly this
73/// set; the compile-time validator
74/// (`SubprocessProcessSpawnerFactory::build`) rejects any other
75/// `{...}` token with `CompileError::InvalidSpec`.
76pub const EMBED_PLACEHOLDERS: [&str; 8] = [
77    "system",
78    "system_file",
79    "prompt",
80    "model",
81    "tools_csv",
82    "work_dir",
83    "task_id",
84    "attempt",
85];
86
87/// GH #83 — the compile-time-baked EmbedAgent invocation: a resolved
88/// [`SubprocessDef`](mlua_swarm_schema::SubprocessDef) template merged
89/// with the agent profile (system prompt / model / tools) and the
90/// `Runner::Subprocess` overrides. When [`ProcessSpawner::embed`] carries
91/// one of these, `spawn` takes the EmbedAgent path: materialize the
92/// worker payload via `Engine::fetch_worker_payload` (never the
93/// `fetch_prompt`-only single-directive path), render the closed
94/// placeholder set into argv/stdin/env/cwd, and normalize stdout per
95/// [`SubprocessOutput`]. When `None`, `spawn` keeps the historical
96/// spec-based behavior byte-for-byte.
97#[derive(Debug, Clone, Default)]
98pub struct EmbedTemplate {
99    /// Full argv (element 0 = program), unrendered — elements may carry
100    /// placeholder tokens.
101    pub argv: Vec<String>,
102    /// stdin template. `Some` = render + pipe to the child's stdin;
103    /// `None` = no stdin write (immediate EOF).
104    pub stdin: Option<String>,
105    /// Extra env vars (appended to the `MSE_*` token exports); values may
106    /// carry placeholder tokens.
107    pub env: BTreeMap<String, String>,
108    /// Working-directory template (`Runner::Subprocess` `overrides.cwd`
109    /// already merged in at compile time — it wins over the template's
110    /// own `cwd`). `None` = fall back to the spawn-time `{work_dir}`
111    /// source; if that is also absent the child inherits the server CWD
112    /// (historical behavior).
113    pub cwd: Option<String>,
114    /// stdout normalization declaration. `None` = historical JSON-or-raw.
115    pub output: Option<SubprocessOutput>,
116    /// Compile-time-baked `profile.system_prompt` (pre-render template —
117    /// minijinja slot expansion happens at spawn, mirroring
118    /// `OperatorSpawner`).
119    pub system_prompt: Option<String>,
120    /// `{model}` placeholder value (overrides.model > profile.model).
121    pub model: Option<String>,
122    /// `{tools_csv}` placeholder value (overrides.tools > profile.tools,
123    /// CSV-joined).
124    pub tools_csv: String,
125}
126
127/// Wire protocol used to receive `OutputEvent`s from the child's
128/// stdout. `None` means plain mode — the default — which buffers stdout
129/// in full and folds it into a single `Final`.
130#[derive(Debug, Clone)]
131pub enum StreamMode {
132    /// One line per `OutputEvent` JSON (newline-delimited JSON).
133    NdjsonLines,
134    /// The `text/event-stream` form. Each event is a `data: <json>`
135    /// line terminated by a blank line. `event:` / `id:` / `retry:`
136    /// lines are ignored (MVP: only `data` lines are picked up).
137    /// Multiple `data` lines are concatenated into a single JSON
138    /// payload.
139    SseEvents,
140    /// Binary form: repeated `[u32 BE length][N bytes JSON payload]`.
141    /// Handy for LLM tools and high-frequency streams that want to
142    /// avoid text-framing overhead.
143    LengthPrefixed,
144}
145
146/// A `SpawnerAdapter` that runs a worker as an external OS process
147/// (a binary or a `sh -c` one-liner). Configured with the builder
148/// methods below, then registered like any other spawner.
149#[derive(Debug)]
150pub struct ProcessSpawner {
151    /// Binary (or `sh`, when built via [`ProcessSpawner::run`]) to
152    /// execute.
153    pub program: String,
154    /// Extra arguments passed to `program`, in order.
155    pub args: Vec<String>,
156    /// Whether to pipe the directive into the child's stdin — most LLM
157    /// CLIs read prompts that way (`--prompt -` and friends). When
158    /// `false`, the directive is appended to `args` instead.
159    pub use_stdin: bool,
160    /// `Some(mode)` — streaming mode. `None` — plain mode (the default).
161    pub stream_mode: Option<StreamMode>,
162    /// GH #83 — `Some` switches `spawn` to the EmbedAgent template path
163    /// (see [`EmbedTemplate`]); `None` (the default) keeps the historical
164    /// spec-based behavior byte-for-byte.
165    pub embed: Option<EmbedTemplate>,
166}
167
168impl ProcessSpawner {
169    /// Builder entry point: spawn `program` with no args, stdin piping
170    /// on, and plain mode.
171    pub fn new(program: impl Into<String>) -> Self {
172        Self {
173            program: program.into(),
174            args: Vec::new(),
175            use_stdin: true,
176            stream_mode: None,
177            embed: None,
178        }
179    }
180
181    /// Appends a single argument.
182    pub fn arg(mut self, a: impl Into<String>) -> Self {
183        self.args.push(a.into());
184        self
185    }
186
187    /// Appends multiple arguments at once.
188    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
189        self.args.extend(args.into_iter().map(|a| a.into()));
190        self
191    }
192
193    /// Sets whether the directive/prompt is piped to the child's stdin
194    /// (`true`, the default) or appended as a trailing arg (`false`).
195    pub fn use_stdin(mut self, v: bool) -> Self {
196        self.use_stdin = v;
197        self
198    }
199
200    /// Set the streaming mode. Default: `None` (plain mode).
201    pub fn stream_mode(mut self, mode: StreamMode) -> Self {
202        self.stream_mode = Some(mode);
203        self
204    }
205
206    /// Reset to plain mode explicitly — sets `stream_mode` to `None`.
207    pub fn plain(mut self) -> Self {
208        self.stream_mode = None;
209        self
210    }
211
212    /// Compatibility helper: `ndjson(true)` is equivalent to
213    /// `.stream_mode(StreamMode::NdjsonLines)`, and `ndjson(false)` to
214    /// `.plain()`. A deprecation candidate, kept around for now.
215    pub fn ndjson(mut self, v: bool) -> Self {
216        self.stream_mode = if v {
217            Some(StreamMode::NdjsonLines)
218        } else {
219            None
220        };
221        self
222    }
223
224    /// Convenience builder that runs a one-liner via `sh -c '<cmd>'`.
225    pub fn run(cmd: impl Into<String>) -> Self {
226        Self {
227            program: "sh".into(),
228            args: vec!["-c".into(), cmd.into()],
229            use_stdin: true,
230            stream_mode: None,
231            embed: None,
232        }
233    }
234
235    /// Builder that spawns an arbitrary binary directly, without going
236    /// through a shell.
237    pub fn cmd(program: impl Into<String>) -> Self {
238        Self {
239            program: program.into(),
240            args: Vec::new(),
241            use_stdin: true,
242            stream_mode: None,
243            embed: None,
244        }
245    }
246
247    /// GH #83: switch this spawner to the EmbedAgent template path.
248    pub fn embed(mut self, template: EmbedTemplate) -> Self {
249        self.embed = Some(template);
250        self
251    }
252}
253
254#[async_trait]
255impl SpawnerAdapter for ProcessSpawner {
256    async fn spawn(
257        &self,
258        engine: &Engine,
259        ctx: &Ctx,
260        task_id: StepId,
261        attempt: u32,
262        token: CapToken,
263    ) -> Result<Box<dyn Worker>, SpawnError> {
264        // GH #83: the EmbedAgent template path materializes the full
265        // worker payload (system + prompt + context) and renders the
266        // declared template — never the fetch_prompt-only path below.
267        if let Some(embed) = &self.embed {
268            return self
269                .spawn_embed(embed, engine, ctx, task_id, attempt, token)
270                .await;
271        }
272        // design intent: `prompt` is obtained through
273        // `engine.fetch_prompt`, replacing the removed `directive`
274        // argument. `ProcessSpawner` snapshots it here and pushes it
275        // either into the child's stdin or the tail of `args`. If a
276        // child process wants to pull `fetch_prompt` itself, it can
277        // rebuild the token from the `MSE_TOKEN_*` env vars and call
278        // the engine — that lives in a separate spawner implementation.
279        let directive = engine
280            .fetch_prompt(&token, &task_id)
281            .await
282            .map_err(|e| SpawnError::Internal(format!("fetch_prompt: {e}")))?;
283        // Subprocess spawner consumes `directive` as `String` (command arg /
284        // stdin). Issue #18 boundary render — Value flows end-to-end upstream.
285        let directive = crate::core::engine::render_directive_to_string(&directive);
286
287        let mut cmd = Command::new(&self.program);
288        cmd.args(&self.args)
289            .env("MSE_TOKEN_AGENT_ID", &token.agent_id)
290            .env("MSE_TOKEN_NONCE", &token.nonce)
291            .env("MSE_TASK_ID", task_id.as_str())
292            .env("MSE_ATTEMPT", attempt.to_string())
293            .env("MSE_CTX_AGENT", &ctx.agent)
294            .stdin(Stdio::piped())
295            .stdout(Stdio::piped())
296            .stderr(Stdio::piped());
297
298        if !self.use_stdin {
299            cmd.arg(&directive);
300        }
301
302        let mut child = cmd
303            .spawn()
304            .map_err(|e| SpawnError::Internal(format!("spawn failed: {e}")))?;
305
306        if self.use_stdin {
307            if let Some(mut stdin) = child.stdin.take() {
308                stdin
309                    .write_all(directive.as_bytes())
310                    .await
311                    .map_err(|e| SpawnError::Internal(format!("stdin write: {e}")))?;
312                drop(stdin); // EOF for child
313            }
314        }
315
316        let cancel = CancellationToken::new();
317        let cancel_inner = cancel.clone();
318        let worker_id = WorkerId::new();
319        // issue #11: surface the minted WorkerId in the trace log.
320        tracing::debug!(worker_id = %worker_id, step_id = %task_id, "worker spawned (subprocess)");
321        let (tx, rx) = oneshot::channel();
322        // design intent: hand `engine` / `token` to the spawn task so it can emit
323        // OutputEvent via submit_output (side-by-side with the WorkerResult
324        // oneshot path).
325        let engine_for_emit = engine.clone();
326        let token_for_emit = token.clone();
327        let task_id_for_emit = task_id.clone();
328        let stream_mode = self.stream_mode.clone();
329
330        tokio::spawn(async move {
331            let result: Result<WorkerResult, WorkerError> = if let Some(mode) = stream_mode {
332                // ── streaming mode: read stdout as a chunk stream per protocol,
333                // pushing each chunk to submit_output as an OutputEvent. When we
334                // see a Final, fold {value, ok} into WorkerResult.
335                run_streaming_mode(
336                    mode,
337                    child,
338                    &engine_for_emit,
339                    &token_for_emit,
340                    &task_id_for_emit,
341                    attempt,
342                    cancel_inner,
343                )
344                .await
345            } else {
346                // ── plain mode (default): buffer all stdout, JSON parse
347                // once, fold a single Final, then emit engine.submit_output(Final) in parallel.
348                let result = tokio::select! {
349                    output = child.wait_with_output() => {
350                        match output {
351                            Ok(out) => {
352                                let stdout = String::from_utf8_lossy(&out.stdout).to_string();
353                                let value: Value = serde_json::from_str(stdout.trim())
354                                    .unwrap_or_else(|_| serde_json::json!({
355                                        "raw": stdout.trim_end(),
356                                        "stderr": String::from_utf8_lossy(&out.stderr).to_string(),
357                                    }));
358                                Ok(WorkerResult { value, ok: out.status.success() })
359                            }
360                            Err(e) => Err(WorkerError::Failed(format!("wait_with_output: {e}"))),
361                        }
362                    }
363                    _ = cancel_inner.cancelled() => Err(WorkerError::Cancelled),
364                };
365                if let Ok(wr) = &result {
366                    let ev = OutputEvent::Final {
367                        content: ContentRef::Inline {
368                            value: wr.value.clone(),
369                        },
370                        ok: wr.ok,
371                    };
372                    let _ = engine_for_emit
373                        .submit_output(&token_for_emit, &task_id_for_emit, attempt, ev)
374                        .await;
375                }
376                result
377            };
378            // signal-only: the value travels through output_tail.
379            let signal: Result<(), WorkerError> = result.map(|_| ());
380            let _ = tx.send(signal);
381        });
382
383        Ok(Box::new(ProcessWorker {
384            handler: WorkerJoinHandler {
385                worker_id,
386                cancel,
387                completion: rx,
388            },
389        }))
390    }
391}
392
393/// GH #83 — spawn-time values behind the closed placeholder set. `None`
394/// entries are "no source for this spawn": referencing their token in a
395/// template is a fail-loud `SpawnError`, never a silent empty string
396/// (`{system}` alone renders empty when no system prompt exists, because
397/// an agent without a profile is a legal EmbedAgent).
398struct EmbedVars {
399    system: String,
400    system_file: Option<String>,
401    prompt: String,
402    model: Option<String>,
403    tools_csv: String,
404    work_dir: Option<String>,
405    task_id: String,
406    attempt: String,
407}
408
409impl EmbedVars {
410    /// Resolves one closed-set token to its spawn-time value.
411    /// `Ok(None)` = not a placeholder (literal brace text — left alone);
412    /// `Err` = closed-set token referenced but no value source exists for
413    /// this spawn (fail-loud with the actionable cause).
414    fn lookup(&self, token: &str) -> Result<Option<&str>, SpawnError> {
415        let (value, cause): (Option<&str>, &str) = match token {
416            "system" => (Some(self.system.as_str()), ""),
417            "system_file" => (
418                self.system_file.as_deref(),
419                "no system prompt was baked for this attempt (agent has no profile.system_prompt?)",
420            ),
421            "prompt" => (Some(self.prompt.as_str()), ""),
422            "model" => (
423                self.model.as_deref(),
424                "no model declared (set profile.model or Runner::Subprocess overrides.model)",
425            ),
426            "tools_csv" => (Some(self.tools_csv.as_str()), ""),
427            "work_dir" => (
428                self.work_dir.as_deref(),
429                "no work_dir/project_root in the agent context view and no overrides.cwd",
430            ),
431            "task_id" => (Some(self.task_id.as_str()), ""),
432            "attempt" => (Some(self.attempt.as_str()), ""),
433            _ => return Ok(None),
434        };
435        match value {
436            Some(v) => Ok(Some(v)),
437            None => Err(SpawnError::Internal(format!(
438                "placeholder {{{token}}}: {cause}"
439            ))),
440        }
441    }
442
443    /// Pure closed-set substitution — no conditionals, no loops, no
444    /// expression evaluation. Single left-to-right pass over the
445    /// TEMPLATE only: substituted values are copied to the output and
446    /// never re-scanned, so runtime data (e.g. a prompt containing a
447    /// literal `{model}`) can never trigger a second-order substitution
448    /// or a spurious missing-source failure. Unknown `{...}` tokens in
449    /// the template were already rejected at compile time; non-token
450    /// brace text (JSON literals etc.) is copied through verbatim.
451    fn render(&self, tmpl: &str) -> Result<String, SpawnError> {
452        let mut out = String::with_capacity(tmpl.len());
453        let mut rest = tmpl;
454        while let Some(start) = rest.find('{') {
455            out.push_str(&rest[..start]);
456            let after = &rest[start + 1..];
457            let Some(end) = after.find('}') else {
458                // Unmatched '{' — literal tail.
459                out.push_str(&rest[start..]);
460                return Ok(out);
461            };
462            let token = &after[..end];
463            match self.lookup(token)? {
464                Some(value) => {
465                    out.push_str(value);
466                    rest = &after[end + 1..];
467                }
468                None => {
469                    // Not a placeholder — emit the '{' and keep scanning
470                    // right after it, so a placeholder nested inside
471                    // literal braces (e.g. a JSON-wrapped stdin like
472                    // `{"task": "{prompt}"}`) is still found.
473                    out.push('{');
474                    rest = after;
475                }
476            }
477        }
478        out.push_str(rest);
479        Ok(out)
480    }
481}
482
483/// GH #83 — does any template string of `embed` reference `token`?
484fn embed_references(embed: &EmbedTemplate, token: &str) -> bool {
485    embed.argv.iter().any(|a| a.contains(token))
486        || embed.stdin.as_deref().is_some_and(|s| s.contains(token))
487        || embed.env.values().any(|v| v.contains(token))
488        || embed.cwd.as_deref().is_some_and(|c| c.contains(token))
489}
490
491/// GH #83 — plain-mode stdout normalization under a
492/// [`SubprocessOutput`] declaration. `decl = None` reproduces the
493/// historical JSON-or-raw wrap byte-for-byte (same expression as the
494/// spec-based path).
495fn normalize_plain_output(
496    out: &std::process::Output,
497    decl: Option<&SubprocessOutput>,
498) -> WorkerResult {
499    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
500    let stderr = || String::from_utf8_lossy(&out.stderr).to_string();
501    let exit_ok = out.status.success();
502
503    let Some(decl) = decl else {
504        let value: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|_| {
505            serde_json::json!({
506                "raw": stdout.trim_end(),
507                "stderr": stderr(),
508            })
509        });
510        return WorkerResult { value, ok: exit_ok };
511    };
512
513    let parsed: Result<Value, _> = serde_json::from_str(stdout.trim());
514    let parsed = match parsed {
515        Ok(v) => v,
516        Err(e) => {
517            if decl.format.as_deref() == Some("json") {
518                // Declared-JSON stdout that does not parse is a failed
519                // step, regardless of the exit code.
520                return WorkerResult {
521                    value: serde_json::json!({
522                        "raw": stdout.trim_end(),
523                        "stderr": stderr(),
524                        "parse_error": e.to_string(),
525                    }),
526                    ok: false,
527                };
528            }
529            // Lenient (format undeclared): historical raw wrap; pointer
530            // extraction cannot apply to a non-JSON stdout.
531            return WorkerResult {
532                value: serde_json::json!({
533                    "raw": stdout.trim_end(),
534                    "stderr": stderr(),
535                }),
536                ok: exit_ok,
537            };
538        }
539    };
540
541    let value = match decl.result_ptr.as_deref() {
542        Some(ptr) => match parsed.pointer(ptr) {
543            Some(v) => v.clone(),
544            None => {
545                return WorkerResult {
546                    value: serde_json::json!({
547                        "error": format!("result_ptr '{ptr}' not found in stdout JSON"),
548                        "raw": parsed,
549                        "stderr": stderr(),
550                    }),
551                    ok: false,
552                }
553            }
554        },
555        None => parsed.clone(),
556    };
557
558    let ok = match decl.ok_from.as_deref() {
559        None | Some("exit_code") => exit_ok,
560        Some(ptr) => parsed.pointer(ptr) == Some(&Value::Bool(true)),
561    };
562
563    WorkerResult { value, ok }
564}
565
566impl ProcessSpawner {
567    /// GH #83 — the EmbedAgent spawn path: materialize the same
568    /// `WorkerPayload` the Operator/HTTP paths use (bake → fetch, never
569    /// the `fetch_prompt`-only single-directive path), render the closed
570    /// placeholder set into the declared template, exec, and normalize
571    /// stdout per the template's `output` declaration.
572    async fn spawn_embed(
573        &self,
574        embed: &EmbedTemplate,
575        engine: &Engine,
576        ctx: &Ctx,
577        task_id: StepId,
578        attempt: u32,
579        token: CapToken,
580    ) -> Result<Box<dyn Worker>, SpawnError> {
581        // 1. Render + bake the system prompt (same minijinja slot
582        //    expansion as OperatorSpawner::spawn — bake BEFORE fetch so
583        //    fetch_worker_payload reads it back from `s.systems`).
584        let prompt_value = engine
585            .fetch_prompt(&token, &task_id)
586            .await
587            .map_err(|e| SpawnError::Internal(format!("fetch_prompt: {e}")))?;
588        let system = match embed.system_prompt.as_deref() {
589            Some(tmpl) => {
590                let slots = crate::operator::render::slots_from_prompt(&prompt_value);
591                let rendered = crate::operator::render::render_system(tmpl, &slots)
592                    .map_err(|e| SpawnError::Internal(format!("render system_prompt: {e}")))?;
593                Some(rendered)
594            }
595            None => None,
596        };
597        engine
598            .bake_worker_system_prompt(&task_id, attempt, system.clone())
599            .await
600            .map_err(|e| SpawnError::Internal(format!("bake system_prompt: {e}")))?;
601
602        // 2. Materialize the canonical worker payload. `{prompt}` comes
603        //    from here; `{system}` uses the locally rendered value so the
604        //    GH #31 system_ref threshold (which may clear
605        //    `payload.system` in favor of a by-reference pointer) cannot
606        //    silently empty the placeholder.
607        let payload = engine
608            .fetch_worker_payload(&token, &task_id)
609            .await
610            .map_err(|e| SpawnError::Internal(format!("fetch_worker_payload: {e}")))?;
611
612        // 3. `{system_file}` — unconditional materialization, only when
613        //    the template actually references it.
614        let system_file = if embed_references(embed, "{system_file}") {
615            match engine
616                .materialize_system_file(&task_id, attempt)
617                .await
618                .map_err(|e| SpawnError::Internal(format!("materialize system_file: {e}")))?
619            {
620                Some(path) => Some(path.display().to_string()),
621                None => {
622                    return Err(SpawnError::Internal(
623                        "placeholder {system_file}: no system prompt was baked for this \
624                         attempt (agent has no profile.system_prompt?)"
625                            .into(),
626                    ))
627                }
628            }
629        } else {
630            None
631        };
632
633        // 4. `{work_dir}` source — the GH #20 context view (work_dir,
634        //    falling back to project_root).
635        let view = AgentContextView::materialized_or_from_ctx(ctx);
636        let work_dir = view.work_dir.clone().or_else(|| view.project_root.clone());
637
638        let vars = EmbedVars {
639            system: system.unwrap_or_default(),
640            system_file,
641            prompt: payload.prompt.clone(),
642            model: embed.model.clone(),
643            tools_csv: embed.tools_csv.clone(),
644            work_dir,
645            task_id: task_id.to_string(),
646            attempt: attempt.to_string(),
647        };
648
649        // 5. Render argv / env / cwd / stdin.
650        if embed.argv.is_empty() {
651            return Err(SpawnError::Internal(
652                "embed template: argv must not be empty".into(),
653            ));
654        }
655        let mut rendered_argv = Vec::with_capacity(embed.argv.len());
656        for a in &embed.argv {
657            rendered_argv.push(vars.render(a)?);
658        }
659        let mut rendered_env = Vec::with_capacity(embed.env.len());
660        for (k, v) in &embed.env {
661            rendered_env.push((k.clone(), vars.render(v)?));
662        }
663        let rendered_cwd = match embed.cwd.as_deref() {
664            Some(c) => Some(vars.render(c)?),
665            None => None,
666        };
667        let rendered_stdin = match embed.stdin.as_deref() {
668            Some(s) => Some(vars.render(s)?),
669            None => None,
670        };
671
672        let mut cmd = Command::new(&rendered_argv[0]);
673        cmd.args(&rendered_argv[1..])
674            .env("MSE_TOKEN_AGENT_ID", &token.agent_id)
675            .env("MSE_TOKEN_NONCE", &token.nonce)
676            .env("MSE_TASK_ID", task_id.as_str())
677            .env("MSE_ATTEMPT", attempt.to_string())
678            .env("MSE_CTX_AGENT", &ctx.agent)
679            .stdin(Stdio::piped())
680            .stdout(Stdio::piped())
681            .stderr(Stdio::piped());
682        for (k, v) in &rendered_env {
683            cmd.env(k, v);
684        }
685        if let Some(cwd) = &rendered_cwd {
686            cmd.current_dir(cwd);
687        }
688
689        let mut child = cmd
690            .spawn()
691            .map_err(|e| SpawnError::Internal(format!("spawn failed: {e}")))?;
692
693        if let Some(stdin_body) = rendered_stdin {
694            if let Some(mut stdin) = child.stdin.take() {
695                stdin
696                    .write_all(stdin_body.as_bytes())
697                    .await
698                    .map_err(|e| SpawnError::Internal(format!("stdin write: {e}")))?;
699                drop(stdin); // EOF for child
700            }
701        } else {
702            // No stdin declared: close the pipe so the child sees EOF.
703            drop(child.stdin.take());
704        }
705
706        let cancel = CancellationToken::new();
707        let cancel_inner = cancel.clone();
708        let worker_id = WorkerId::new();
709        tracing::debug!(worker_id = %worker_id, step_id = %task_id, "worker spawned (subprocess embed)");
710        let (tx, rx) = oneshot::channel();
711        let engine_for_emit = engine.clone();
712        let token_for_emit = token.clone();
713        let task_id_for_emit = task_id.clone();
714        let stream_mode = self.stream_mode.clone();
715        let output_decl = embed.output.clone();
716
717        tokio::spawn(async move {
718            let result: Result<WorkerResult, WorkerError> = if let Some(mode) = stream_mode {
719                // Streaming keeps the existing event protocol untouched
720                // (output normalization is a plain-mode declaration).
721                run_streaming_mode(
722                    mode,
723                    child,
724                    &engine_for_emit,
725                    &token_for_emit,
726                    &task_id_for_emit,
727                    attempt,
728                    cancel_inner,
729                )
730                .await
731            } else {
732                let result = tokio::select! {
733                    output = child.wait_with_output() => {
734                        match output {
735                            Ok(out) => Ok(normalize_plain_output(&out, output_decl.as_ref())),
736                            Err(e) => Err(WorkerError::Failed(format!("wait_with_output: {e}"))),
737                        }
738                    }
739                    _ = cancel_inner.cancelled() => Err(WorkerError::Cancelled),
740                };
741                if let Ok(wr) = &result {
742                    let ev = OutputEvent::Final {
743                        content: ContentRef::Inline {
744                            value: wr.value.clone(),
745                        },
746                        ok: wr.ok,
747                    };
748                    let _ = engine_for_emit
749                        .submit_output(&token_for_emit, &task_id_for_emit, attempt, ev)
750                        .await;
751                }
752                result
753            };
754            let signal: Result<(), WorkerError> = result.map(|_| ());
755            let _ = tx.send(signal);
756        });
757
758        Ok(Box::new(ProcessWorker {
759            handler: WorkerJoinHandler {
760                worker_id,
761                cancel,
762                completion: rx,
763            },
764        }))
765    }
766}
767
768/// Concrete Worker type for the Subprocess kind — the handle to a
769/// child OS process's `wait_with_output` / stream wait. Embeds a
770/// `WorkerJoinHandler` to carry the async signal.
771pub struct ProcessWorker {
772    /// The completion-signal handle for this child process's spawned
773    /// wait task.
774    pub handler: WorkerJoinHandler,
775}
776
777#[async_trait]
778impl Worker for ProcessWorker {
779    fn id(&self) -> &WorkerId {
780        &self.handler.worker_id
781    }
782    fn cancel_token(&self) -> CancellationToken {
783        self.handler.cancel.clone()
784    }
785    async fn join(self: Box<Self>) -> Result<(), WorkerError> {
786        self.handler.await_completion().await
787    }
788}
789
790/// Streaming-mode dispatcher. Picks one of the three reader functions
791/// per protocol. Owns the shared boilerplate — final tracking, child
792/// wait, synthetic-final emit, `WorkerResult` construction — so each
793/// reader only has to worry about parsing its protocol and calling
794/// `submit_output` per chunk.
795async fn run_streaming_mode(
796    mode: StreamMode,
797    mut child: tokio::process::Child,
798    engine: &Engine,
799    token: &CapToken,
800    task_id: &StepId,
801    attempt: u32,
802    cancel: CancellationToken,
803) -> Result<WorkerResult, WorkerError> {
804    let stdout = child
805        .stdout
806        .take()
807        .ok_or_else(|| WorkerError::Failed("streaming: stdout pipe missing".into()))?;
808
809    let last_final = match mode {
810        StreamMode::NdjsonLines => {
811            read_ndjson(stdout, engine, token, task_id, attempt, cancel.clone()).await?
812        }
813        StreamMode::SseEvents => {
814            read_sse(stdout, engine, token, task_id, attempt, cancel.clone()).await?
815        }
816        StreamMode::LengthPrefixed => {
817            read_length_prefixed(stdout, engine, token, task_id, attempt, cancel.clone()).await?
818        }
819    };
820
821    let status = child
822        .wait()
823        .await
824        .map_err(|e| WorkerError::Failed(format!("streaming wait: {e}")))?;
825
826    match last_final {
827        Some((value, ok)) => Ok(WorkerResult {
828            value,
829            ok: ok && status.success(),
830        }),
831        None => {
832            // No Final present: push a synthesized Final so dispatch can pull it from output_tail.
833            let value = serde_json::json!({
834                "raw": "",
835                "note": "streaming mode: no Final event received",
836                "exit_success": status.success(),
837            });
838            let _ = engine
839                .submit_output(
840                    token,
841                    task_id,
842                    attempt,
843                    OutputEvent::Final {
844                        content: ContentRef::Inline {
845                            value: value.clone(),
846                        },
847                        ok: false,
848                    },
849                )
850                .await;
851            Ok(WorkerResult { value, ok: false })
852        }
853    }
854}
855
856/// Shared per-chunk parse + emit path. Called by every reader once it
857/// has recovered an `OutputEvent`.
858async fn forward_event(
859    engine: &Engine,
860    token: &CapToken,
861    task_id: &StepId,
862    attempt: u32,
863    ev: OutputEvent,
864    last_final: &mut Option<(Value, bool)>,
865) {
866    if let OutputEvent::Final { content, ok } = &ev {
867        let value = match content {
868            ContentRef::Inline { value } => value.clone(),
869            ContentRef::FileRef {
870                path,
871                mime,
872                size_hint,
873            } => serde_json::json!({
874                "file_ref": path.to_string_lossy(),
875                "mime": mime,
876                "size_hint": size_hint,
877            }),
878        };
879        *last_final = Some((value, *ok));
880    }
881    let _ = engine.submit_output(token, task_id, attempt, ev).await;
882}
883
884/// NDJSON: one line per JSON `OutputEvent`. Unparseable lines are
885/// skipped.
886async fn read_ndjson(
887    stdout: tokio::process::ChildStdout,
888    engine: &Engine,
889    token: &CapToken,
890    task_id: &StepId,
891    attempt: u32,
892    cancel: CancellationToken,
893) -> Result<Option<(Value, bool)>, WorkerError> {
894    let mut reader = BufReader::new(stdout).lines();
895    let mut last_final = None;
896    loop {
897        tokio::select! {
898            line_res = reader.next_line() => match line_res {
899                Ok(Some(line)) => {
900                    let trimmed = line.trim();
901                    if trimmed.is_empty() { continue; }
902                    if let Ok(ev) = serde_json::from_str::<OutputEvent>(trimmed) {
903                        forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
904                    }
905                }
906                Ok(None) => break,
907                Err(e) => return Err(WorkerError::Failed(format!("ndjson read: {e}"))),
908            },
909            _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
910        }
911    }
912    Ok(last_final)
913}
914
915/// SSE: one event per `data: <json>` line followed by a blank line.
916/// `event:` / `id:` / `retry:` lines are ignored; multiple `data:`
917/// lines are LF-joined into a single JSON payload (a W3C-SSE-spec MVP).
918async fn read_sse(
919    stdout: tokio::process::ChildStdout,
920    engine: &Engine,
921    token: &CapToken,
922    task_id: &StepId,
923    attempt: u32,
924    cancel: CancellationToken,
925) -> Result<Option<(Value, bool)>, WorkerError> {
926    let mut reader = BufReader::new(stdout).lines();
927    let mut last_final = None;
928    let mut data_buf = String::new();
929    loop {
930        tokio::select! {
931            line_res = reader.next_line() => match line_res {
932                Ok(Some(line)) => {
933                    if line.is_empty() {
934                        // Empty line = event terminator, so flush.
935                        if !data_buf.is_empty() {
936                            if let Ok(ev) = serde_json::from_str::<OutputEvent>(data_buf.trim()) {
937                                forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
938                            }
939                            data_buf.clear();
940                        }
941                    } else if let Some(rest) = line.strip_prefix("data:") {
942                        // SSE spec: optional space after colon
943                        let payload = rest.strip_prefix(' ').unwrap_or(rest);
944                        if !data_buf.is_empty() {
945                            data_buf.push('\n');
946                        }
947                        data_buf.push_str(payload);
948                    }
949                    // else: event: / id: / retry: / comment line → skip
950                }
951                Ok(None) => {
952                    // EOF: flush any leftover data_buf as the final event.
953                    if !data_buf.is_empty() {
954                        if let Ok(ev) = serde_json::from_str::<OutputEvent>(data_buf.trim()) {
955                            forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
956                        }
957                    }
958                    break;
959                }
960                Err(e) => return Err(WorkerError::Failed(format!("sse read: {e}"))),
961            },
962            _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
963        }
964    }
965    Ok(last_final)
966}
967
968/// Length-prefixed: repeated `[u32 BE length][N bytes JSON payload]`
969/// binary frames.
970async fn read_length_prefixed(
971    mut stdout: tokio::process::ChildStdout,
972    engine: &Engine,
973    token: &CapToken,
974    task_id: &StepId,
975    attempt: u32,
976    cancel: CancellationToken,
977) -> Result<Option<(Value, bool)>, WorkerError> {
978    use tokio::io::AsyncReadExt;
979    let mut last_final = None;
980    loop {
981        // Read the 4-byte length prefix (racing against cancel via select).
982        let mut len_buf = [0u8; 4];
983        let read_fut = stdout.read_exact(&mut len_buf);
984        let read_res = tokio::select! {
985            r = read_fut => r,
986            _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
987        };
988        match read_res {
989            Ok(_) => {}
990            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, // clean EOF
991            Err(e) => return Err(WorkerError::Failed(format!("len read: {e}"))),
992        }
993        let len = u32::from_be_bytes(len_buf) as usize;
994        if len == 0 || len > 16 * 1024 * 1024 {
995            // 0 or > 16 MiB is treated as a frame error; break out.
996            break;
997        }
998        let mut payload = vec![0u8; len];
999        let read_fut = stdout.read_exact(&mut payload);
1000        let read_res = tokio::select! {
1001            r = read_fut => r,
1002            _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
1003        };
1004        if read_res.is_err() {
1005            break;
1006        }
1007        if let Ok(ev) = serde_json::from_slice::<OutputEvent>(&payload) {
1008            forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
1009        }
1010    }
1011    Ok(last_final)
1012}
1013
1014#[cfg(test)]
1015mod embed_tests {
1016    use super::*;
1017
1018    fn vars() -> EmbedVars {
1019        EmbedVars {
1020            system: "SYS".to_string(),
1021            system_file: Some("/tmp/sys.md".to_string()),
1022            prompt: "do the task".to_string(),
1023            model: Some("small".to_string()),
1024            tools_csv: "Read,Grep".to_string(),
1025            work_dir: Some("/tmp/wd".to_string()),
1026            task_id: "ST-1".to_string(),
1027            attempt: "1".to_string(),
1028        }
1029    }
1030
1031    #[test]
1032    fn render_substitutes_every_closed_set_token() {
1033        let v = vars();
1034        let out = v
1035            .render("{system}|{system_file}|{prompt}|{model}|{tools_csv}|{work_dir}|{task_id}|{attempt}")
1036            .expect("render");
1037        assert_eq!(
1038            out,
1039            "SYS|/tmp/sys.md|do the task|small|Read,Grep|/tmp/wd|ST-1|1"
1040        );
1041    }
1042
1043    #[test]
1044    fn render_leaves_non_placeholder_braces_alone() {
1045        let v = vars();
1046        let out = v
1047            .render(r#"echo '{"result": "{prompt}"}'"#)
1048            .expect("render");
1049        assert_eq!(out, r#"echo '{"result": "do the task"}'"#);
1050    }
1051
1052    #[test]
1053    fn render_fails_loud_when_model_referenced_but_absent() {
1054        let mut v = vars();
1055        v.model = None;
1056        let err = v.render("--model {model}").unwrap_err();
1057        let msg = format!("{err:?}");
1058        assert!(msg.contains("{model}"), "actionable token in error: {msg}");
1059        assert!(msg.contains("profile.model"), "actionable cause: {msg}");
1060    }
1061
1062    #[test]
1063    fn render_fails_loud_when_work_dir_referenced_but_absent() {
1064        let mut v = vars();
1065        v.work_dir = None;
1066        let err = v.render("{work_dir}").unwrap_err();
1067        let msg = format!("{err:?}");
1068        assert!(msg.contains("{work_dir}"), "actionable token: {msg}");
1069    }
1070
1071    #[test]
1072    fn render_empty_system_is_legal() {
1073        let mut v = vars();
1074        v.system = String::new();
1075        assert_eq!(v.render("[{system}]").expect("render"), "[]");
1076    }
1077
1078    /// Holistic-review fix: substituted VALUES are never re-scanned — a
1079    /// prompt containing a literal closed-set token must pass through
1080    /// verbatim, and must not trigger a missing-source failure for a
1081    /// token the template itself never references.
1082    #[test]
1083    fn render_never_substitutes_inside_substituted_values() {
1084        let mut v = vars();
1085        v.prompt = "please mention {model} and {work_dir} literally".to_string();
1086        v.model = None; // template does not reference {model} → no source needed
1087        v.work_dir = None;
1088        let out = v.render("task: {prompt}").expect("render");
1089        assert_eq!(out, "task: please mention {model} and {work_dir} literally");
1090    }
1091
1092    /// Placeholders nested inside literal braces (JSON-wrapped stdin)
1093    /// are still substituted by the single-pass scan.
1094    #[test]
1095    fn render_substitutes_placeholder_nested_in_literal_braces() {
1096        let v = vars();
1097        let out = v.render(r#"{"task": "{prompt}", "n": 1}"#).expect("render");
1098        assert_eq!(out, r#"{"task": "do the task", "n": 1}"#);
1099    }
1100
1101    #[test]
1102    fn embed_references_scans_argv_stdin_env_cwd() {
1103        let mut t = EmbedTemplate {
1104            argv: vec!["cat".to_string()],
1105            ..Default::default()
1106        };
1107        assert!(!embed_references(&t, "{system_file}"));
1108        t.stdin = Some("{system_file}".to_string());
1109        assert!(embed_references(&t, "{system_file}"));
1110        t.stdin = None;
1111        t.env.insert("X".to_string(), "{system_file}".to_string());
1112        assert!(embed_references(&t, "{system_file}"));
1113        t.env.clear();
1114        t.cwd = Some("{system_file}".to_string());
1115        assert!(embed_references(&t, "{system_file}"));
1116    }
1117
1118    fn fake_output(stdout: &str, stderr: &str, code: i32) -> std::process::Output {
1119        #[cfg(unix)]
1120        use std::os::unix::process::ExitStatusExt;
1121        #[cfg(windows)]
1122        use std::os::windows::process::ExitStatusExt;
1123        use std::process::ExitStatus;
1124        #[cfg(unix)]
1125        let status = ExitStatus::from_raw(code << 8);
1126        #[cfg(windows)]
1127        let status = ExitStatus::from_raw(code as u32);
1128        std::process::Output {
1129            status,
1130            stdout: stdout.as_bytes().to_vec(),
1131            stderr: stderr.as_bytes().to_vec(),
1132        }
1133    }
1134
1135    /// Historical byte-compat pin: with NO output declaration, the
1136    /// normalize result must equal the spec-based path's expression —
1137    /// lenient JSON parse, raw wrap on failure, `ok = exit success`.
1138    #[test]
1139    fn normalize_without_decl_is_historical_json_or_raw() {
1140        let out = fake_output(r#"{"a": 1}"#, "", 0);
1141        let wr = normalize_plain_output(&out, None);
1142        assert_eq!(wr.value, serde_json::json!({"a": 1}));
1143        assert!(wr.ok);
1144
1145        let out = fake_output("plain text\n", "warned", 0);
1146        let wr = normalize_plain_output(&out, None);
1147        assert_eq!(
1148            wr.value,
1149            serde_json::json!({"raw": "plain text", "stderr": "warned"})
1150        );
1151        assert!(wr.ok);
1152
1153        let out = fake_output("boom", "", 1);
1154        let wr = normalize_plain_output(&out, None);
1155        assert!(!wr.ok, "non-zero exit is a failed step");
1156    }
1157
1158    #[test]
1159    fn normalize_result_ptr_extracts_declared_value() {
1160        let decl = SubprocessOutput {
1161            format: Some("json".to_string()),
1162            result_ptr: Some("/result".to_string()),
1163            ok_from: Some("exit_code".to_string()),
1164        };
1165        let out = fake_output(r#"{"result": {"answer": 42}, "noise": true}"#, "", 0);
1166        let wr = normalize_plain_output(&out, Some(&decl));
1167        assert_eq!(wr.value, serde_json::json!({"answer": 42}));
1168        assert!(wr.ok);
1169    }
1170
1171    #[test]
1172    fn normalize_declared_json_unparsable_stdout_fails_loud() {
1173        let decl = SubprocessOutput {
1174            format: Some("json".to_string()),
1175            result_ptr: None,
1176            ok_from: None,
1177        };
1178        let out = fake_output("not json at all", "stderr text", 0);
1179        let wr = normalize_plain_output(&out, Some(&decl));
1180        assert!(!wr.ok, "declared-JSON unparsable stdout is a failed step");
1181        assert_eq!(wr.value["raw"], "not json at all");
1182        assert_eq!(wr.value["stderr"], "stderr text");
1183        assert!(wr.value["parse_error"].is_string());
1184    }
1185
1186    #[test]
1187    fn normalize_missing_result_ptr_fails_loud_with_actionable_value() {
1188        let decl = SubprocessOutput {
1189            format: Some("json".to_string()),
1190            result_ptr: Some("/missing".to_string()),
1191            ok_from: None,
1192        };
1193        let out = fake_output(r#"{"present": 1}"#, "", 0);
1194        let wr = normalize_plain_output(&out, Some(&decl));
1195        assert!(!wr.ok);
1196        assert!(wr.value["error"]
1197            .as_str()
1198            .expect("actionable error message")
1199            .contains("/missing"));
1200    }
1201
1202    #[test]
1203    fn normalize_ok_from_pointer_reads_boolean() {
1204        let decl = SubprocessOutput {
1205            format: Some("json".to_string()),
1206            result_ptr: Some("/result".to_string()),
1207            ok_from: Some("/ok".to_string()),
1208        };
1209        // Pointer true → ok even though we also check it beats exit code.
1210        let out = fake_output(r#"{"result": "r", "ok": true}"#, "", 0);
1211        let wr = normalize_plain_output(&out, Some(&decl));
1212        assert!(wr.ok);
1213        // Pointer false → failed step despite exit 0.
1214        let out = fake_output(r#"{"result": "r", "ok": false}"#, "", 0);
1215        let wr = normalize_plain_output(&out, Some(&decl));
1216        assert!(!wr.ok);
1217        // Pointer missing / non-bool → failed step.
1218        let out = fake_output(r#"{"result": "r", "ok": "yes"}"#, "", 0);
1219        let wr = normalize_plain_output(&out, Some(&decl));
1220        assert!(!wr.ok);
1221    }
1222}