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
202 let mut cmd = Command::new(&self.program);
203 cmd.args(&self.args)
204 .env("MSE_TOKEN_AGENT_ID", &token.agent_id)
205 .env("MSE_TOKEN_NONCE", &token.nonce)
206 .env("MSE_TASK_ID", task_id.as_str())
207 .env("MSE_ATTEMPT", attempt.to_string())
208 .env("MSE_CTX_AGENT", &ctx.agent)
209 .stdin(Stdio::piped())
210 .stdout(Stdio::piped())
211 .stderr(Stdio::piped());
212
213 if !self.use_stdin {
214 cmd.arg(&directive);
215 }
216
217 let mut child = cmd
218 .spawn()
219 .map_err(|e| SpawnError::Internal(format!("spawn failed: {e}")))?;
220
221 if self.use_stdin {
222 if let Some(mut stdin) = child.stdin.take() {
223 stdin
224 .write_all(directive.as_bytes())
225 .await
226 .map_err(|e| SpawnError::Internal(format!("stdin write: {e}")))?;
227 drop(stdin); // EOF for child
228 }
229 }
230
231 let cancel = CancellationToken::new();
232 let cancel_inner = cancel.clone();
233 let worker_id = WorkerId::new();
234 // issue #11: surface the minted WorkerId in the trace log.
235 tracing::debug!(worker_id = %worker_id, step_id = %task_id, "worker spawned (subprocess)");
236 let (tx, rx) = oneshot::channel();
237 // design intent: hand `engine` / `token` to the spawn task so it can emit
238 // OutputEvent via submit_output (side-by-side with the WorkerResult
239 // oneshot path).
240 let engine_for_emit = engine.clone();
241 let token_for_emit = token.clone();
242 let task_id_for_emit = task_id.clone();
243 let stream_mode = self.stream_mode.clone();
244
245 tokio::spawn(async move {
246 let result: Result<WorkerResult, WorkerError> = if let Some(mode) = stream_mode {
247 // ── streaming mode: read stdout as a chunk stream per protocol,
248 // pushing each chunk to submit_output as an OutputEvent. When we
249 // see a Final, fold {value, ok} into WorkerResult.
250 run_streaming_mode(
251 mode,
252 child,
253 &engine_for_emit,
254 &token_for_emit,
255 &task_id_for_emit,
256 attempt,
257 cancel_inner,
258 )
259 .await
260 } else {
261 // ── plain mode (default): buffer all stdout, JSON parse
262 // once, fold a single Final, then emit engine.submit_output(Final) in parallel.
263 let result = tokio::select! {
264 output = child.wait_with_output() => {
265 match output {
266 Ok(out) => {
267 let stdout = String::from_utf8_lossy(&out.stdout).to_string();
268 let value: Value = serde_json::from_str(stdout.trim())
269 .unwrap_or_else(|_| serde_json::json!({
270 "raw": stdout.trim_end(),
271 "stderr": String::from_utf8_lossy(&out.stderr).to_string(),
272 }));
273 Ok(WorkerResult { value, ok: out.status.success() })
274 }
275 Err(e) => Err(WorkerError::Failed(format!("wait_with_output: {e}"))),
276 }
277 }
278 _ = cancel_inner.cancelled() => Err(WorkerError::Cancelled),
279 };
280 if let Ok(wr) = &result {
281 let ev = OutputEvent::Final {
282 content: ContentRef::Inline {
283 value: wr.value.clone(),
284 },
285 ok: wr.ok,
286 };
287 let _ = engine_for_emit
288 .submit_output(&token_for_emit, &task_id_for_emit, attempt, ev)
289 .await;
290 }
291 result
292 };
293 // signal-only: the value travels through output_tail.
294 let signal: Result<(), WorkerError> = result.map(|_| ());
295 let _ = tx.send(signal);
296 });
297
298 Ok(Box::new(ProcessWorker {
299 handler: WorkerJoinHandler {
300 worker_id,
301 cancel,
302 completion: rx,
303 },
304 }))
305 }
306}
307
308/// Concrete Worker type for the Subprocess kind — the handle to a
309/// child OS process's `wait_with_output` / stream wait. Embeds a
310/// `WorkerJoinHandler` to carry the async signal.
311pub struct ProcessWorker {
312 /// The completion-signal handle for this child process's spawned
313 /// wait task.
314 pub handler: WorkerJoinHandler,
315}
316
317#[async_trait]
318impl Worker for ProcessWorker {
319 fn id(&self) -> &WorkerId {
320 &self.handler.worker_id
321 }
322 fn cancel_token(&self) -> CancellationToken {
323 self.handler.cancel.clone()
324 }
325 async fn join(self: Box<Self>) -> Result<(), WorkerError> {
326 self.handler.await_completion().await
327 }
328}
329
330/// Streaming-mode dispatcher. Picks one of the three reader functions
331/// per protocol. Owns the shared boilerplate — final tracking, child
332/// wait, synthetic-final emit, `WorkerResult` construction — so each
333/// reader only has to worry about parsing its protocol and calling
334/// `submit_output` per chunk.
335async fn run_streaming_mode(
336 mode: StreamMode,
337 mut child: tokio::process::Child,
338 engine: &Engine,
339 token: &CapToken,
340 task_id: &StepId,
341 attempt: u32,
342 cancel: CancellationToken,
343) -> Result<WorkerResult, WorkerError> {
344 let stdout = child
345 .stdout
346 .take()
347 .ok_or_else(|| WorkerError::Failed("streaming: stdout pipe missing".into()))?;
348
349 let last_final = match mode {
350 StreamMode::NdjsonLines => {
351 read_ndjson(stdout, engine, token, task_id, attempt, cancel.clone()).await?
352 }
353 StreamMode::SseEvents => {
354 read_sse(stdout, engine, token, task_id, attempt, cancel.clone()).await?
355 }
356 StreamMode::LengthPrefixed => {
357 read_length_prefixed(stdout, engine, token, task_id, attempt, cancel.clone()).await?
358 }
359 };
360
361 let status = child
362 .wait()
363 .await
364 .map_err(|e| WorkerError::Failed(format!("streaming wait: {e}")))?;
365
366 match last_final {
367 Some((value, ok)) => Ok(WorkerResult {
368 value,
369 ok: ok && status.success(),
370 }),
371 None => {
372 // No Final present: push a synthesized Final so dispatch can pull it from output_tail.
373 let value = serde_json::json!({
374 "raw": "",
375 "note": "streaming mode: no Final event received",
376 "exit_success": status.success(),
377 });
378 let _ = engine
379 .submit_output(
380 token,
381 task_id,
382 attempt,
383 OutputEvent::Final {
384 content: ContentRef::Inline {
385 value: value.clone(),
386 },
387 ok: false,
388 },
389 )
390 .await;
391 Ok(WorkerResult { value, ok: false })
392 }
393 }
394}
395
396/// Shared per-chunk parse + emit path. Called by every reader once it
397/// has recovered an `OutputEvent`.
398async fn forward_event(
399 engine: &Engine,
400 token: &CapToken,
401 task_id: &StepId,
402 attempt: u32,
403 ev: OutputEvent,
404 last_final: &mut Option<(Value, bool)>,
405) {
406 if let OutputEvent::Final { content, ok } = &ev {
407 let value = match content {
408 ContentRef::Inline { value } => value.clone(),
409 ContentRef::FileRef {
410 path,
411 mime,
412 size_hint,
413 } => serde_json::json!({
414 "file_ref": path.to_string_lossy(),
415 "mime": mime,
416 "size_hint": size_hint,
417 }),
418 };
419 *last_final = Some((value, *ok));
420 }
421 let _ = engine.submit_output(token, task_id, attempt, ev).await;
422}
423
424/// NDJSON: one line per JSON `OutputEvent`. Unparseable lines are
425/// skipped.
426async fn read_ndjson(
427 stdout: tokio::process::ChildStdout,
428 engine: &Engine,
429 token: &CapToken,
430 task_id: &StepId,
431 attempt: u32,
432 cancel: CancellationToken,
433) -> Result<Option<(Value, bool)>, WorkerError> {
434 let mut reader = BufReader::new(stdout).lines();
435 let mut last_final = None;
436 loop {
437 tokio::select! {
438 line_res = reader.next_line() => match line_res {
439 Ok(Some(line)) => {
440 let trimmed = line.trim();
441 if trimmed.is_empty() { continue; }
442 if let Ok(ev) = serde_json::from_str::<OutputEvent>(trimmed) {
443 forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
444 }
445 }
446 Ok(None) => break,
447 Err(e) => return Err(WorkerError::Failed(format!("ndjson read: {e}"))),
448 },
449 _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
450 }
451 }
452 Ok(last_final)
453}
454
455/// SSE: one event per `data: <json>` line followed by a blank line.
456/// `event:` / `id:` / `retry:` lines are ignored; multiple `data:`
457/// lines are LF-joined into a single JSON payload (a W3C-SSE-spec MVP).
458async fn read_sse(
459 stdout: tokio::process::ChildStdout,
460 engine: &Engine,
461 token: &CapToken,
462 task_id: &StepId,
463 attempt: u32,
464 cancel: CancellationToken,
465) -> Result<Option<(Value, bool)>, WorkerError> {
466 let mut reader = BufReader::new(stdout).lines();
467 let mut last_final = None;
468 let mut data_buf = String::new();
469 loop {
470 tokio::select! {
471 line_res = reader.next_line() => match line_res {
472 Ok(Some(line)) => {
473 if line.is_empty() {
474 // Empty line = event terminator, so flush.
475 if !data_buf.is_empty() {
476 if let Ok(ev) = serde_json::from_str::<OutputEvent>(data_buf.trim()) {
477 forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
478 }
479 data_buf.clear();
480 }
481 } else if let Some(rest) = line.strip_prefix("data:") {
482 // SSE spec: optional space after colon
483 let payload = rest.strip_prefix(' ').unwrap_or(rest);
484 if !data_buf.is_empty() {
485 data_buf.push('\n');
486 }
487 data_buf.push_str(payload);
488 }
489 // else: event: / id: / retry: / comment line → skip
490 }
491 Ok(None) => {
492 // EOF: flush any leftover data_buf as the final event.
493 if !data_buf.is_empty() {
494 if let Ok(ev) = serde_json::from_str::<OutputEvent>(data_buf.trim()) {
495 forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
496 }
497 }
498 break;
499 }
500 Err(e) => return Err(WorkerError::Failed(format!("sse read: {e}"))),
501 },
502 _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
503 }
504 }
505 Ok(last_final)
506}
507
508/// Length-prefixed: repeated `[u32 BE length][N bytes JSON payload]`
509/// binary frames.
510async fn read_length_prefixed(
511 mut stdout: tokio::process::ChildStdout,
512 engine: &Engine,
513 token: &CapToken,
514 task_id: &StepId,
515 attempt: u32,
516 cancel: CancellationToken,
517) -> Result<Option<(Value, bool)>, WorkerError> {
518 use tokio::io::AsyncReadExt;
519 let mut last_final = None;
520 loop {
521 // Read the 4-byte length prefix (racing against cancel via select).
522 let mut len_buf = [0u8; 4];
523 let read_fut = stdout.read_exact(&mut len_buf);
524 let read_res = tokio::select! {
525 r = read_fut => r,
526 _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
527 };
528 match read_res {
529 Ok(_) => {}
530 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, // clean EOF
531 Err(e) => return Err(WorkerError::Failed(format!("len read: {e}"))),
532 }
533 let len = u32::from_be_bytes(len_buf) as usize;
534 if len == 0 || len > 16 * 1024 * 1024 {
535 // 0 or > 16 MiB is treated as a frame error; break out.
536 break;
537 }
538 let mut payload = vec![0u8; len];
539 let read_fut = stdout.read_exact(&mut payload);
540 let read_res = tokio::select! {
541 r = read_fut => r,
542 _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
543 };
544 if read_res.is_err() {
545 break;
546 }
547 if let Ok(ev) = serde_json::from_slice::<OutputEvent>(&payload) {
548 forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
549 }
550 }
551 Ok(last_final)
552}