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::ctx::Ctx;
54use crate::core::engine::Engine;
55use crate::types::{CapToken, StepId, WorkerId};
56use crate::worker::adapter::{SpawnError, SpawnerAdapter, WorkerError, WorkerResult};
57use crate::worker::output::{ContentRef, OutputEvent};
58use crate::worker::{Worker, WorkerJoinHandler};
59use async_trait::async_trait;
60use serde_json::Value;
61use std::process::Stdio;
62use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
63use tokio::process::Command;
64use tokio::sync::oneshot;
65use tokio_util::sync::CancellationToken;
66
67/// Wire protocol used to receive `OutputEvent`s from the child's
68/// stdout. `None` means plain mode — the default — which buffers stdout
69/// in full and folds it into a single `Final`.
70#[derive(Debug, Clone)]
71pub enum StreamMode {
72    /// One line per `OutputEvent` JSON (newline-delimited JSON).
73    NdjsonLines,
74    /// The `text/event-stream` form. Each event is a `data: <json>`
75    /// line terminated by a blank line. `event:` / `id:` / `retry:`
76    /// lines are ignored (MVP: only `data` lines are picked up).
77    /// Multiple `data` lines are concatenated into a single JSON
78    /// payload.
79    SseEvents,
80    /// Binary form: repeated `[u32 BE length][N bytes JSON payload]`.
81    /// Handy for LLM tools and high-frequency streams that want to
82    /// avoid text-framing overhead.
83    LengthPrefixed,
84}
85
86/// A `SpawnerAdapter` that runs a worker as an external OS process
87/// (a binary or a `sh -c` one-liner). Configured with the builder
88/// methods below, then registered like any other spawner.
89pub struct ProcessSpawner {
90    /// Binary (or `sh`, when built via [`ProcessSpawner::run`]) to
91    /// execute.
92    pub program: String,
93    /// Extra arguments passed to `program`, in order.
94    pub args: Vec<String>,
95    /// Whether to pipe the directive into the child's stdin — most LLM
96    /// CLIs read prompts that way (`--prompt -` and friends). When
97    /// `false`, the directive is appended to `args` instead.
98    pub use_stdin: bool,
99    /// `Some(mode)` — streaming mode. `None` — plain mode (the default).
100    pub stream_mode: Option<StreamMode>,
101}
102
103impl ProcessSpawner {
104    /// Builder entry point: spawn `program` with no args, stdin piping
105    /// on, and plain mode.
106    pub fn new(program: impl Into<String>) -> Self {
107        Self {
108            program: program.into(),
109            args: Vec::new(),
110            use_stdin: true,
111            stream_mode: None,
112        }
113    }
114
115    /// Appends a single argument.
116    pub fn arg(mut self, a: impl Into<String>) -> Self {
117        self.args.push(a.into());
118        self
119    }
120
121    /// Appends multiple arguments at once.
122    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
123        self.args.extend(args.into_iter().map(|a| a.into()));
124        self
125    }
126
127    /// Sets whether the directive/prompt is piped to the child's stdin
128    /// (`true`, the default) or appended as a trailing arg (`false`).
129    pub fn use_stdin(mut self, v: bool) -> Self {
130        self.use_stdin = v;
131        self
132    }
133
134    /// Set the streaming mode. Default: `None` (plain mode).
135    pub fn stream_mode(mut self, mode: StreamMode) -> Self {
136        self.stream_mode = Some(mode);
137        self
138    }
139
140    /// Reset to plain mode explicitly — sets `stream_mode` to `None`.
141    pub fn plain(mut self) -> Self {
142        self.stream_mode = None;
143        self
144    }
145
146    /// Compatibility helper: `ndjson(true)` is equivalent to
147    /// `.stream_mode(StreamMode::NdjsonLines)`, and `ndjson(false)` to
148    /// `.plain()`. A deprecation candidate, kept around for now.
149    pub fn ndjson(mut self, v: bool) -> Self {
150        self.stream_mode = if v {
151            Some(StreamMode::NdjsonLines)
152        } else {
153            None
154        };
155        self
156    }
157
158    /// Convenience builder that runs a one-liner via `sh -c '<cmd>'`.
159    pub fn run(cmd: impl Into<String>) -> Self {
160        Self {
161            program: "sh".into(),
162            args: vec!["-c".into(), cmd.into()],
163            use_stdin: true,
164            stream_mode: None,
165        }
166    }
167
168    /// Builder that spawns an arbitrary binary directly, without going
169    /// through a shell.
170    pub fn cmd(program: impl Into<String>) -> Self {
171        Self {
172            program: program.into(),
173            args: Vec::new(),
174            use_stdin: true,
175            stream_mode: None,
176        }
177    }
178}
179
180#[async_trait]
181impl SpawnerAdapter for ProcessSpawner {
182    async fn spawn(
183        &self,
184        engine: &Engine,
185        ctx: &Ctx,
186        task_id: StepId,
187        attempt: u32,
188        token: CapToken,
189    ) -> Result<Box<dyn Worker>, SpawnError> {
190        // design intent: `prompt` is obtained through
191        // `engine.fetch_prompt`, replacing the removed `directive`
192        // argument. `ProcessSpawner` snapshots it here and pushes it
193        // either into the child's stdin or the tail of `args`. If a
194        // child process wants to pull `fetch_prompt` itself, it can
195        // rebuild the token from the `MSE_TOKEN_*` env vars and call
196        // the engine — that lives in a separate spawner implementation.
197        let directive = engine
198            .fetch_prompt(&token, &task_id)
199            .await
200            .map_err(|e| SpawnError::Internal(format!("fetch_prompt: {e}")))?;
201        // Subprocess spawner consumes `directive` as `String` (command arg /
202        // stdin). Issue #18 boundary render — Value flows end-to-end upstream.
203        let directive = crate::core::engine::render_directive_to_string(&directive);
204
205        let mut cmd = Command::new(&self.program);
206        cmd.args(&self.args)
207            .env("MSE_TOKEN_AGENT_ID", &token.agent_id)
208            .env("MSE_TOKEN_NONCE", &token.nonce)
209            .env("MSE_TASK_ID", task_id.as_str())
210            .env("MSE_ATTEMPT", attempt.to_string())
211            .env("MSE_CTX_AGENT", &ctx.agent)
212            .stdin(Stdio::piped())
213            .stdout(Stdio::piped())
214            .stderr(Stdio::piped());
215
216        if !self.use_stdin {
217            cmd.arg(&directive);
218        }
219
220        let mut child = cmd
221            .spawn()
222            .map_err(|e| SpawnError::Internal(format!("spawn failed: {e}")))?;
223
224        if self.use_stdin {
225            if let Some(mut stdin) = child.stdin.take() {
226                stdin
227                    .write_all(directive.as_bytes())
228                    .await
229                    .map_err(|e| SpawnError::Internal(format!("stdin write: {e}")))?;
230                drop(stdin); // EOF for child
231            }
232        }
233
234        let cancel = CancellationToken::new();
235        let cancel_inner = cancel.clone();
236        let worker_id = WorkerId::new();
237        // issue #11: surface the minted WorkerId in the trace log.
238        tracing::debug!(worker_id = %worker_id, step_id = %task_id, "worker spawned (subprocess)");
239        let (tx, rx) = oneshot::channel();
240        // design intent: hand `engine` / `token` to the spawn task so it can emit
241        // OutputEvent via submit_output (side-by-side with the WorkerResult
242        // oneshot path).
243        let engine_for_emit = engine.clone();
244        let token_for_emit = token.clone();
245        let task_id_for_emit = task_id.clone();
246        let stream_mode = self.stream_mode.clone();
247
248        tokio::spawn(async move {
249            let result: Result<WorkerResult, WorkerError> = if let Some(mode) = stream_mode {
250                // ── streaming mode: read stdout as a chunk stream per protocol,
251                // pushing each chunk to submit_output as an OutputEvent. When we
252                // see a Final, fold {value, ok} into WorkerResult.
253                run_streaming_mode(
254                    mode,
255                    child,
256                    &engine_for_emit,
257                    &token_for_emit,
258                    &task_id_for_emit,
259                    attempt,
260                    cancel_inner,
261                )
262                .await
263            } else {
264                // ── plain mode (default): buffer all stdout, JSON parse
265                // once, fold a single Final, then emit engine.submit_output(Final) in parallel.
266                let result = tokio::select! {
267                    output = child.wait_with_output() => {
268                        match output {
269                            Ok(out) => {
270                                let stdout = String::from_utf8_lossy(&out.stdout).to_string();
271                                let value: Value = serde_json::from_str(stdout.trim())
272                                    .unwrap_or_else(|_| serde_json::json!({
273                                        "raw": stdout.trim_end(),
274                                        "stderr": String::from_utf8_lossy(&out.stderr).to_string(),
275                                    }));
276                                Ok(WorkerResult { value, ok: out.status.success() })
277                            }
278                            Err(e) => Err(WorkerError::Failed(format!("wait_with_output: {e}"))),
279                        }
280                    }
281                    _ = cancel_inner.cancelled() => Err(WorkerError::Cancelled),
282                };
283                if let Ok(wr) = &result {
284                    let ev = OutputEvent::Final {
285                        content: ContentRef::Inline {
286                            value: wr.value.clone(),
287                        },
288                        ok: wr.ok,
289                    };
290                    let _ = engine_for_emit
291                        .submit_output(&token_for_emit, &task_id_for_emit, attempt, ev)
292                        .await;
293                }
294                result
295            };
296            // signal-only: the value travels through output_tail.
297            let signal: Result<(), WorkerError> = result.map(|_| ());
298            let _ = tx.send(signal);
299        });
300
301        Ok(Box::new(ProcessWorker {
302            handler: WorkerJoinHandler {
303                worker_id,
304                cancel,
305                completion: rx,
306            },
307        }))
308    }
309}
310
311/// Concrete Worker type for the Subprocess kind — the handle to a
312/// child OS process's `wait_with_output` / stream wait. Embeds a
313/// `WorkerJoinHandler` to carry the async signal.
314pub struct ProcessWorker {
315    /// The completion-signal handle for this child process's spawned
316    /// wait task.
317    pub handler: WorkerJoinHandler,
318}
319
320#[async_trait]
321impl Worker for ProcessWorker {
322    fn id(&self) -> &WorkerId {
323        &self.handler.worker_id
324    }
325    fn cancel_token(&self) -> CancellationToken {
326        self.handler.cancel.clone()
327    }
328    async fn join(self: Box<Self>) -> Result<(), WorkerError> {
329        self.handler.await_completion().await
330    }
331}
332
333/// Streaming-mode dispatcher. Picks one of the three reader functions
334/// per protocol. Owns the shared boilerplate — final tracking, child
335/// wait, synthetic-final emit, `WorkerResult` construction — so each
336/// reader only has to worry about parsing its protocol and calling
337/// `submit_output` per chunk.
338async fn run_streaming_mode(
339    mode: StreamMode,
340    mut child: tokio::process::Child,
341    engine: &Engine,
342    token: &CapToken,
343    task_id: &StepId,
344    attempt: u32,
345    cancel: CancellationToken,
346) -> Result<WorkerResult, WorkerError> {
347    let stdout = child
348        .stdout
349        .take()
350        .ok_or_else(|| WorkerError::Failed("streaming: stdout pipe missing".into()))?;
351
352    let last_final = match mode {
353        StreamMode::NdjsonLines => {
354            read_ndjson(stdout, engine, token, task_id, attempt, cancel.clone()).await?
355        }
356        StreamMode::SseEvents => {
357            read_sse(stdout, engine, token, task_id, attempt, cancel.clone()).await?
358        }
359        StreamMode::LengthPrefixed => {
360            read_length_prefixed(stdout, engine, token, task_id, attempt, cancel.clone()).await?
361        }
362    };
363
364    let status = child
365        .wait()
366        .await
367        .map_err(|e| WorkerError::Failed(format!("streaming wait: {e}")))?;
368
369    match last_final {
370        Some((value, ok)) => Ok(WorkerResult {
371            value,
372            ok: ok && status.success(),
373        }),
374        None => {
375            // No Final present: push a synthesized Final so dispatch can pull it from output_tail.
376            let value = serde_json::json!({
377                "raw": "",
378                "note": "streaming mode: no Final event received",
379                "exit_success": status.success(),
380            });
381            let _ = engine
382                .submit_output(
383                    token,
384                    task_id,
385                    attempt,
386                    OutputEvent::Final {
387                        content: ContentRef::Inline {
388                            value: value.clone(),
389                        },
390                        ok: false,
391                    },
392                )
393                .await;
394            Ok(WorkerResult { value, ok: false })
395        }
396    }
397}
398
399/// Shared per-chunk parse + emit path. Called by every reader once it
400/// has recovered an `OutputEvent`.
401async fn forward_event(
402    engine: &Engine,
403    token: &CapToken,
404    task_id: &StepId,
405    attempt: u32,
406    ev: OutputEvent,
407    last_final: &mut Option<(Value, bool)>,
408) {
409    if let OutputEvent::Final { content, ok } = &ev {
410        let value = match content {
411            ContentRef::Inline { value } => value.clone(),
412            ContentRef::FileRef {
413                path,
414                mime,
415                size_hint,
416            } => serde_json::json!({
417                "file_ref": path.to_string_lossy(),
418                "mime": mime,
419                "size_hint": size_hint,
420            }),
421        };
422        *last_final = Some((value, *ok));
423    }
424    let _ = engine.submit_output(token, task_id, attempt, ev).await;
425}
426
427/// NDJSON: one line per JSON `OutputEvent`. Unparseable lines are
428/// skipped.
429async fn read_ndjson(
430    stdout: tokio::process::ChildStdout,
431    engine: &Engine,
432    token: &CapToken,
433    task_id: &StepId,
434    attempt: u32,
435    cancel: CancellationToken,
436) -> Result<Option<(Value, bool)>, WorkerError> {
437    let mut reader = BufReader::new(stdout).lines();
438    let mut last_final = None;
439    loop {
440        tokio::select! {
441            line_res = reader.next_line() => match line_res {
442                Ok(Some(line)) => {
443                    let trimmed = line.trim();
444                    if trimmed.is_empty() { continue; }
445                    if let Ok(ev) = serde_json::from_str::<OutputEvent>(trimmed) {
446                        forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
447                    }
448                }
449                Ok(None) => break,
450                Err(e) => return Err(WorkerError::Failed(format!("ndjson read: {e}"))),
451            },
452            _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
453        }
454    }
455    Ok(last_final)
456}
457
458/// SSE: one event per `data: <json>` line followed by a blank line.
459/// `event:` / `id:` / `retry:` lines are ignored; multiple `data:`
460/// lines are LF-joined into a single JSON payload (a W3C-SSE-spec MVP).
461async fn read_sse(
462    stdout: tokio::process::ChildStdout,
463    engine: &Engine,
464    token: &CapToken,
465    task_id: &StepId,
466    attempt: u32,
467    cancel: CancellationToken,
468) -> Result<Option<(Value, bool)>, WorkerError> {
469    let mut reader = BufReader::new(stdout).lines();
470    let mut last_final = None;
471    let mut data_buf = String::new();
472    loop {
473        tokio::select! {
474            line_res = reader.next_line() => match line_res {
475                Ok(Some(line)) => {
476                    if line.is_empty() {
477                        // Empty line = event terminator, so flush.
478                        if !data_buf.is_empty() {
479                            if let Ok(ev) = serde_json::from_str::<OutputEvent>(data_buf.trim()) {
480                                forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
481                            }
482                            data_buf.clear();
483                        }
484                    } else if let Some(rest) = line.strip_prefix("data:") {
485                        // SSE spec: optional space after colon
486                        let payload = rest.strip_prefix(' ').unwrap_or(rest);
487                        if !data_buf.is_empty() {
488                            data_buf.push('\n');
489                        }
490                        data_buf.push_str(payload);
491                    }
492                    // else: event: / id: / retry: / comment line → skip
493                }
494                Ok(None) => {
495                    // EOF: flush any leftover data_buf as the final event.
496                    if !data_buf.is_empty() {
497                        if let Ok(ev) = serde_json::from_str::<OutputEvent>(data_buf.trim()) {
498                            forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
499                        }
500                    }
501                    break;
502                }
503                Err(e) => return Err(WorkerError::Failed(format!("sse read: {e}"))),
504            },
505            _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
506        }
507    }
508    Ok(last_final)
509}
510
511/// Length-prefixed: repeated `[u32 BE length][N bytes JSON payload]`
512/// binary frames.
513async fn read_length_prefixed(
514    mut stdout: tokio::process::ChildStdout,
515    engine: &Engine,
516    token: &CapToken,
517    task_id: &StepId,
518    attempt: u32,
519    cancel: CancellationToken,
520) -> Result<Option<(Value, bool)>, WorkerError> {
521    use tokio::io::AsyncReadExt;
522    let mut last_final = None;
523    loop {
524        // Read the 4-byte length prefix (racing against cancel via select).
525        let mut len_buf = [0u8; 4];
526        let read_fut = stdout.read_exact(&mut len_buf);
527        let read_res = tokio::select! {
528            r = read_fut => r,
529            _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
530        };
531        match read_res {
532            Ok(_) => {}
533            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, // clean EOF
534            Err(e) => return Err(WorkerError::Failed(format!("len read: {e}"))),
535        }
536        let len = u32::from_be_bytes(len_buf) as usize;
537        if len == 0 || len > 16 * 1024 * 1024 {
538            // 0 or > 16 MiB is treated as a frame error; break out.
539            break;
540        }
541        let mut payload = vec![0u8; len];
542        let read_fut = stdout.read_exact(&mut payload);
543        let read_res = tokio::select! {
544            r = read_fut => r,
545            _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
546        };
547        if read_res.is_err() {
548            break;
549        }
550        if let Ok(ev) = serde_json::from_slice::<OutputEvent>(&payload) {
551            forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
552        }
553    }
554    Ok(last_final)
555}