Skip to main content

cli_agents/adapters/
mod.rs

1mod claude;
2mod codex;
3mod gemini;
4
5pub use claude::ClaudeAdapter;
6pub use codex::CodexAdapter;
7pub use gemini::GeminiAdapter;
8
9use crate::error::{Error, Result};
10use crate::events::StreamEvent;
11use crate::types::{CliName, RunOptions, RunResult};
12#[cfg(windows)]
13use process_wrap::tokio::JobObject;
14#[cfg(unix)]
15use process_wrap::tokio::ProcessGroup;
16use process_wrap::tokio::TokioChildWrapper;
17use process_wrap::tokio::TokioCommandWrap;
18use std::collections::HashMap;
19use tokio::io::{AsyncBufReadExt, BufReader};
20use tracing::{debug, warn};
21
22/// Trait implemented by each CLI adapter.
23pub trait CliAdapter: Send + Sync {
24    fn name(&self) -> CliName;
25
26    fn run(
27        &self,
28        opts: &RunOptions,
29        emit: &(dyn Fn(StreamEvent) + Send + Sync),
30        cancel: tokio_util::sync::CancellationToken,
31    ) -> impl std::future::Future<Output = crate::error::Result<RunResult>> + Send;
32}
33
34/// Get the adapter for a given CLI.
35pub(crate) fn get_adapter(cli: CliName) -> Box<dyn CliAdapterBoxed> {
36    match cli {
37        CliName::Claude => Box::new(ClaudeAdapter),
38        CliName::Codex => Box::new(CodexAdapter),
39        CliName::Gemini => Box::new(GeminiAdapter),
40    }
41}
42
43/// Object-safe version of [`CliAdapter`] for dynamic dispatch.
44///
45/// Needed because `CliAdapter::run` uses RPITIT (`impl Future`), which makes
46/// the trait non-object-safe. This wrapper boxes the future for `dyn` dispatch.
47/// The blanket impl below bridges the two automatically.
48#[allow(dead_code)]
49pub(crate) trait CliAdapterBoxed: Send + Sync {
50    fn name(&self) -> CliName;
51
52    fn run_boxed<'a>(
53        &'a self,
54        opts: &'a RunOptions,
55        emit: &'a (dyn Fn(StreamEvent) + Send + Sync),
56        cancel: tokio_util::sync::CancellationToken,
57    ) -> std::pin::Pin<
58        Box<dyn std::future::Future<Output = crate::error::Result<RunResult>> + Send + 'a>,
59    >;
60}
61
62impl<T: CliAdapter> CliAdapterBoxed for T {
63    fn name(&self) -> CliName {
64        CliAdapter::name(self)
65    }
66
67    fn run_boxed<'a>(
68        &'a self,
69        opts: &'a RunOptions,
70        emit: &'a (dyn Fn(StreamEvent) + Send + Sync),
71        cancel: tokio_util::sync::CancellationToken,
72    ) -> std::pin::Pin<
73        Box<dyn std::future::Future<Output = crate::error::Result<RunResult>> + Send + 'a>,
74    > {
75        Box::pin(self.run(opts, emit, cancel))
76    }
77}
78
79// ── Shared subprocess infrastructure ──
80
81/// Outcome of a spawned CLI process.
82pub(crate) enum SpawnOutcome {
83    /// Process reached a terminal state on its own (exited, or was signalled).
84    Done {
85        /// The process's exit status, or `None` when a SIGNAL ended it instead.
86        ///
87        /// A signalled process genuinely has no exit code. Substituting one
88        /// makes an out-of-memory kill indistinguishable from an agent that
89        /// cleanly decided it had failed, and [`RunResult::exit_code`] is an
90        /// `Option` precisely so a caller can tell the two apart.
91        exit_code: Option<i32>,
92        /// The signal that terminated the process, when one did. Unix only;
93        /// always `None` elsewhere.
94        signal: Option<i32>,
95        stderr: Option<String>,
96        /// How many stdout lines exceeded `max_bytes` and were dropped.
97        ///
98        /// A dropped line is a lost EVENT, not a lost run — adapters surface
99        /// this as a warning so the loss is never silent.
100        dropped_lines: u64,
101    },
102    /// Process was cancelled via the cancellation token.
103    Cancelled,
104}
105
106/// Parameters for [`spawn_and_stream`].
107pub(crate) struct SpawnParams<'a> {
108    pub cli_label: &'a str,
109    pub binary: &'a str,
110    pub args: &'a [String],
111    pub extra_env: &'a HashMap<String, String>,
112    /// Keys to remove from the inherited parent env before applying `extra_env`.
113    /// Used to prevent leaks like `ANTHROPIC_API_KEY` overriding subscription auth.
114    pub strip_env: &'a [&'static str],
115    pub cwd: &'a str,
116    pub max_bytes: usize,
117    pub cancel: &'a tokio_util::sync::CancellationToken,
118}
119
120/// Spawn a CLI subprocess and stream its stdout line-by-line.
121///
122/// Handles the boilerplate shared across all adapters: process spawning,
123/// stdout buffering with size limits, stderr collection, and cancellation.
124/// Does **not** clone the parent process environment — `Command` inherits it
125/// automatically; only `extra_env` entries are added.
126pub(crate) async fn spawn_and_stream(
127    params: SpawnParams<'_>,
128    mut on_line: impl FnMut(&str) + Send,
129) -> Result<SpawnOutcome> {
130    let SpawnParams {
131        cli_label,
132        binary,
133        args,
134        extra_env,
135        strip_env,
136        cwd,
137        max_bytes,
138        cancel,
139    } = params;
140    debug!(cli = cli_label, binary = %binary, args = ?args, "spawning CLI");
141
142    // ── The child owns a KILL GROUP, on every platform ──
143    //
144    // Cancelling a run has to take the whole tree, not just the process we
145    // spawned: `claude` is a launcher, and the work happens in node processes
146    // below it. Killing only the parent orphans those — they keep running, keep
147    // holding the model session, and keep writing to a pipe nobody reads.
148    //
149    // This used to be `pre_exec(setpgid)` plus `libc::killpg(SIGKILL)`, which is
150    // correct on unix and does not exist on Windows — where the equivalent is a
151    // Job Object, a completely different mechanism with the same purpose.
152    // `process-wrap` is that difference, already written and tested: the unix
153    // arm is the same process-group call, and the Windows arm assigns the child
154    // to a job that dies with it.
155    let mut wrap = TokioCommandWrap::with_new(binary, |cmd| {
156        cmd.args(args);
157        for key in strip_env {
158            cmd.env_remove(key);
159        }
160        cmd.envs(extra_env)
161            .current_dir(cwd)
162            .stdin(std::process::Stdio::null())
163            .stdout(std::process::Stdio::piped())
164            .stderr(std::process::Stdio::piped())
165            .kill_on_drop(true);
166    });
167    #[cfg(unix)]
168    wrap.wrap(ProcessGroup::leader());
169    #[cfg(windows)]
170    wrap.wrap(JobObject);
171
172    let mut child = wrap
173        .spawn()
174        .map_err(|e| Error::Process(format!("failed to spawn {cli_label}: {e}")))?;
175
176    let stdout = child.stdout().take().expect("stdout piped");
177    let stderr = child.stderr().take().expect("stderr piped");
178
179    // stderr is retained as a bounded TAIL. It exists so a failed run can be
180    // explained (`extract_error_message` reads it), and the parting words are
181    // at the end — a chatty CLI logging megabytes must not be held in full
182    // for that. Chunked reads, not lines: a single unterminated line cannot
183    // grow the buffer past the bound either.
184    let stderr_handle = tokio::spawn(async move {
185        use tokio::io::AsyncReadExt;
186        let mut stderr = stderr;
187        let mut buf: Vec<u8> = Vec::new();
188        let mut chunk = [0u8; 8192];
189        loop {
190            match stderr.read(&mut chunk).await {
191                Ok(0) | Err(_) => break,
192                Ok(n) => {
193                    buf.extend_from_slice(&chunk[..n]);
194                    if buf.len() > STDERR_TAIL_BYTES * 2 {
195                        buf.drain(..buf.len() - STDERR_TAIL_BYTES);
196                    }
197                }
198            }
199        }
200        String::from_utf8_lossy(&buf).into_owned()
201    });
202
203    // `max_bytes` bounds what a single LINE may retain — not cumulative
204    // throughput. Every line is handed to `on_line` and released, so the
205    // total streamed volume never lives in memory; a cumulative cap here
206    // used to KILL a healthy long run at the 10MB mark and discard the
207    // whole turn it was carrying. A line that will not fit is consumed to
208    // its newline, dropped, and counted; the run continues.
209    let mut reader = BufReader::new(stdout);
210    let mut line_buf: Vec<u8> = Vec::new();
211    let mut dropped_lines: u64 = 0;
212
213    loop {
214        line_buf.clear();
215        tokio::select! {
216            result = read_line_capped(&mut reader, &mut line_buf, max_bytes) => {
217                match result {
218                    Ok(CappedLine::Eof) => break,
219                    Ok(CappedLine::Line { dropped: true }) => {
220                        dropped_lines += 1;
221                        warn!(cli = cli_label, max_bytes, "dropped a stdout line larger than the retention cap");
222                    }
223                    Ok(CappedLine::Line { dropped: false }) => {
224                        on_line(String::from_utf8_lossy(&line_buf).trim());
225                    }
226                    Err(e) => {
227                        warn!(cli = cli_label, error = %e, "error reading stdout");
228                        break;
229                    }
230                }
231            }
232            _ = cancel.cancelled() => {
233                kill_process_group(&mut child).await;
234                return Ok(SpawnOutcome::Cancelled);
235            }
236        }
237    }
238
239    let status = Box::into_pin(child.wait()).await.map_err(Error::Io)?;
240    // `code()` is `None` for a signalled process. This used to be
241    // `.unwrap_or(1)`, which reported a SIGKILL as a clean `exit 1` and left
242    // callers with no way to recover the difference — the exact ambiguity that
243    // sent a downstream app hunting for an error message a killed process never
244    // wrote. Report what actually happened and let the caller decide.
245    let exit_code = status.code();
246    #[cfg(unix)]
247    let signal = std::os::unix::process::ExitStatusExt::signal(&status);
248    #[cfg(not(unix))]
249    let signal: Option<i32> = None;
250    let stderr_text = stderr_handle.await.unwrap_or_default();
251
252    Ok(SpawnOutcome::Done {
253        exit_code,
254        signal,
255        stderr: if stderr_text.is_empty() {
256            None
257        } else {
258            Some(stderr_text)
259        },
260        dropped_lines,
261    })
262}
263
264/// How much stderr is retained (as a tail — see the reader above).
265const STDERR_TAIL_BYTES: usize = 64 * 1024;
266
267/// Outcome of one capped line read.
268enum CappedLine {
269    /// A complete line is in the buffer — unless `dropped`, in which case the
270    /// line exceeded the cap, the buffer is empty, and the line is gone.
271    Line { dropped: bool },
272    /// End of stream.
273    Eof,
274}
275
276/// Read one `\n`-terminated line, retaining at most `cap` bytes of it.
277///
278/// A line that will not fit is not truncated-and-delivered — a cut JSONL
279/// event is garbage to every parser downstream — it is consumed to its
280/// newline, discarded, and reported as `dropped`. Memory stays bounded by
281/// `cap` plus the reader's own buffer, no matter what the child writes.
282async fn read_line_capped<R: tokio::io::AsyncBufRead + Unpin>(
283    reader: &mut R,
284    buf: &mut Vec<u8>,
285    cap: usize,
286) -> std::io::Result<CappedLine> {
287    let mut dropped = false;
288    loop {
289        let (consumed, line_complete) = {
290            let available = reader.fill_buf().await?;
291            if available.is_empty() {
292                // EOF — a final unterminated line still counts as a line.
293                return Ok(if buf.is_empty() && !dropped {
294                    CappedLine::Eof
295                } else {
296                    CappedLine::Line { dropped }
297                });
298            }
299            match available.iter().position(|&b| b == b'\n') {
300                Some(newline) => {
301                    if !dropped {
302                        if buf.len() + newline <= cap {
303                            buf.extend_from_slice(&available[..newline]);
304                        } else {
305                            dropped = true;
306                            buf.clear();
307                        }
308                    }
309                    (newline + 1, true)
310                }
311                None => {
312                    let n = available.len();
313                    if !dropped {
314                        if buf.len() + n <= cap {
315                            buf.extend_from_slice(available);
316                        } else {
317                            dropped = true;
318                            buf.clear();
319                        }
320                    }
321                    (n, false)
322                }
323            }
324        };
325        reader.consume(consumed);
326        if line_complete {
327            return Ok(CappedLine::Line { dropped });
328        }
329    }
330}
331
332/// The loss a dropped line represents must reach the consumer, not just the
333/// log — every adapter calls this after its spawn completes.
334pub(crate) fn warn_dropped_lines(dropped_lines: u64, max_bytes: usize, emit: &dyn Fn(StreamEvent)) {
335    if dropped_lines > 0 {
336        emit(StreamEvent::Error {
337            message: format!(
338                "{dropped_lines} output line(s) exceeded the {max_bytes}-byte retention cap and were dropped"
339            ),
340            severity: Some(crate::events::Severity::Warning),
341        });
342    }
343}
344
345/// A sentence for a process that died without writing one.
346///
347/// A signalled CLI usually produces NO stderr and no result event — there was
348/// no chance to. Without this the only fact reaching the user is a number, and
349/// the most common case by far (the OS reclaiming memory) reads as an
350/// unexplained failure.
351pub(crate) fn describe_signal(signal: Option<i32>) -> Option<String> {
352    let sig = signal?;
353    Some(match sig {
354        2 => "The agent was interrupted (SIGINT).".to_string(),
355        6 => "The agent aborted (SIGABRT).".to_string(),
356        9 => "The agent was killed (SIGKILL), most often by the system reclaiming memory."
357            .to_string(),
358        11 => "The agent crashed (SIGSEGV).".to_string(),
359        15 => "The agent was terminated (SIGTERM).".to_string(),
360        other => format!("The agent was terminated by signal {other}."),
361    })
362}
363
364/// Extract a user-friendly error message from CLI stderr.
365/// When an agent fails with no text output, this provides something
366/// meaningful to show the user instead of a blank response.
367pub(crate) fn extract_error_message(stderr: Option<&str>) -> Option<String> {
368    let stderr = stderr?;
369    // Find the most informative error line.
370    let msg = stderr
371        .lines()
372        .filter(|l| !l.is_empty())
373        .find(|l| {
374            let lower = l.to_lowercase();
375            lower.contains("error")
376                || lower.contains("limit")
377                || lower.contains("failed")
378                || lower.contains("denied")
379                || lower.contains("unauthorized")
380        })
381        .or_else(|| stderr.lines().rfind(|l| !l.is_empty()));
382    msg.map(|s| s.trim().to_string())
383}
384
385/// Kill the child AND everything it spawned.
386///
387/// `TokioChildWrapper::kill` dispatches to whichever group mechanism was wrapped
388/// on at spawn — the process group on unix, the Job Object on Windows — so the
389/// `#[cfg]` that used to live here is gone. It returns a boxed future, hence the
390/// pin.
391async fn kill_process_group(child: &mut Box<dyn TokioChildWrapper>) {
392    let _ = Box::into_pin(child.kill()).await;
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    /// CANCELLING TAKES THE WHOLE TREE, not just the process we spawned.
400    ///
401    /// This is the contract `setpgid`/`killpg` existed to provide, and it had no
402    /// test — so the swap to `process-wrap` would have been unverifiable, and so
403    /// would any future change to it. It matters because `claude` is a
404    /// launcher: the work runs in node processes underneath. Killing only the
405    /// parent leaves those alive, holding a model session, writing to a pipe
406    /// nobody is reading.
407    ///
408    /// HOW IT PROVES IT WITHOUT TIMING GAMES: the shell writes a marker file,
409    /// spawns a grandchild that would DELETE that file after a delay, then
410    /// sleeps. Cancel immediately. If the group died, the grandchild never runs
411    /// and the marker survives. If only the parent died, the orphan wakes up and
412    /// removes it. The assertion is on a filesystem fact, not on a pid still
413    /// being enumerable, which is what makes it honest on both platforms.
414    ///
415    /// Unix-only for now: it needs a shell that can background a process, and
416    /// the Windows equivalent (`cmd /c start`) has different semantics worth
417    /// writing deliberately rather than transliterating. The Job Object path is
418    /// exercised by CI compiling this file for Windows; that it KILLS the tree
419    /// there is not yet proved. Marked plainly rather than assumed.
420    #[cfg(unix)]
421    #[tokio::test]
422    async fn cancelling_kills_the_grandchild_not_just_the_child() {
423        let dir = tempfile::tempdir().unwrap();
424        let marker = dir.path().join("survivor");
425        std::fs::write(&marker, "alive").unwrap();
426
427        // Grandchild removes the marker after 3s; parent then sleeps 10s.
428        let script = format!("(sleep 3; rm -f '{}') & sleep 10", marker.display());
429        let args = vec!["-c".to_string(), script];
430        let cancel = tokio_util::sync::CancellationToken::new();
431
432        let token = cancel.clone();
433        tokio::spawn(async move {
434            tokio::time::sleep(std::time::Duration::from_millis(300)).await;
435            token.cancel();
436        });
437
438        let outcome = spawn_and_stream(
439            SpawnParams {
440                cli_label: "test",
441                binary: "sh",
442                args: &args,
443                extra_env: &HashMap::new(),
444                strip_env: &[],
445                cwd: dir.path().to_str().unwrap(),
446                max_bytes: 1024,
447                cancel: &cancel,
448            },
449            |_: &str| {},
450        )
451        .await
452        .expect("spawn");
453
454        assert!(
455            matches!(outcome, SpawnOutcome::Cancelled),
456            "run was cancelled"
457        );
458
459        // Past when the grandchild would have deleted it, had it survived.
460        tokio::time::sleep(std::time::Duration::from_secs(5)).await;
461        assert!(
462            marker.exists(),
463            "the grandchild outlived cancellation and deleted the marker — the kill did not reach the process group"
464        );
465    }
466
467    /// A process that ends by SIGNAL has no exit code, and says so.
468    ///
469    /// THE REGRESSION THIS PINS. `spawn_and_stream` used to finish with
470    /// `status.code().unwrap_or(1)`, so a killed process was reported as a
471    /// clean `exit 1`. Downstream that is unrecoverable: an out-of-memory kill
472    /// and an agent that decided it had failed become the same fact, and a
473    /// consumer looking for the reason searches a stderr the process never got
474    /// to write. `sh -c 'kill -9 $$'` reproduces it without timing games — the
475    /// shell signals itself, so the outcome is deterministic.
476    #[cfg(unix)]
477    #[tokio::test]
478    async fn a_signalled_process_reports_the_signal_not_a_fabricated_exit_code() {
479        let args = vec!["-c".to_string(), "kill -9 $$".to_string()];
480        let cancel = tokio_util::sync::CancellationToken::new();
481        let outcome = spawn_and_stream(
482            SpawnParams {
483                cli_label: "test",
484                binary: "sh",
485                args: &args,
486                extra_env: &HashMap::new(),
487                strip_env: &[],
488                cwd: ".",
489                max_bytes: 1024,
490                cancel: &cancel,
491            },
492            |_| {},
493        )
494        .await
495        .expect("spawn should succeed");
496
497        match outcome {
498            SpawnOutcome::Done {
499                exit_code, signal, ..
500            } => {
501                assert_eq!(exit_code, None, "a signalled process has no exit code");
502                assert_eq!(signal, Some(9), "SIGKILL should be reported as itself");
503            }
504            SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
505        }
506    }
507
508    /// An ordinary non-zero exit still reports its code — the change above must
509    /// not turn every failure into `None`.
510    #[tokio::test]
511    async fn a_normal_exit_still_reports_its_code() {
512        let args = vec!["-c".to_string(), "exit 3".to_string()];
513        let cancel = tokio_util::sync::CancellationToken::new();
514        let outcome = spawn_and_stream(
515            SpawnParams {
516                cli_label: "test",
517                binary: "sh",
518                args: &args,
519                extra_env: &HashMap::new(),
520                strip_env: &[],
521                cwd: ".",
522                max_bytes: 1024,
523                cancel: &cancel,
524            },
525            |_| {},
526        )
527        .await
528        .expect("spawn should succeed");
529
530        match outcome {
531            SpawnOutcome::Done {
532                exit_code, signal, ..
533            } => {
534                assert_eq!(exit_code, Some(3));
535                assert_eq!(signal, None, "an ordinary exit was not signalled");
536            }
537            SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
538        }
539    }
540
541    /// The user-facing sentence for the case that writes no stderr at all.
542    #[test]
543    fn describe_signal_names_the_common_kills() {
544        assert!(describe_signal(Some(9)).unwrap().contains("SIGKILL"));
545        assert!(describe_signal(Some(9)).unwrap().contains("memory"));
546        assert!(describe_signal(Some(15)).unwrap().contains("SIGTERM"));
547        assert!(describe_signal(Some(42)).unwrap().contains("42"));
548        assert_eq!(describe_signal(None), None);
549    }
550
551    /// A LONG RUN IS NOT AN ERROR. `max_bytes` used to count cumulative
552    /// throughput and KILL the process when the total crossed it — but every
553    /// line is handed to `on_line` and dropped, so the total was never held in
554    /// memory at all. A 30-minute agent run streaming tens of MB of tool
555    /// events died at the 10MB mark with its entire turn discarded, which is
556    /// how CueFrame's Director lost long turns in production. The cap bounds
557    /// what a single line may RETAIN; the run itself must complete.
558    #[cfg(unix)]
559    #[tokio::test]
560    async fn total_output_beyond_max_bytes_streams_through_and_completes() {
561        // 200 lines × ~100 bytes ≈ 20 KB through a 1 KB cap.
562        let script = "i=0; while [ $i -lt 200 ]; do printf '%0100d\\n' $i; i=$((i+1)); done";
563        let args = vec!["-c".to_string(), script.to_string()];
564        let cancel = tokio_util::sync::CancellationToken::new();
565        let mut lines = 0u32;
566        let outcome = spawn_and_stream(
567            SpawnParams {
568                cli_label: "test",
569                binary: "sh",
570                args: &args,
571                extra_env: &HashMap::new(),
572                strip_env: &[],
573                cwd: ".",
574                max_bytes: 1024,
575                cancel: &cancel,
576            },
577            |_| lines += 1,
578        )
579        .await
580        .expect("a large-but-line-bounded run must not be an error");
581
582        match outcome {
583            SpawnOutcome::Done { exit_code, .. } => assert_eq!(exit_code, Some(0)),
584            SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
585        }
586        assert_eq!(lines, 200, "every line was streamed through");
587    }
588
589    /// One oversized line loses ITSELF, not the run. The line that cannot be
590    /// retained within `max_bytes` is dropped (a truncated JSON event would be
591    /// garbage anyway); the lines after it still arrive and the process still
592    /// reports its own exit.
593    #[cfg(unix)]
594    #[tokio::test]
595    async fn an_oversized_line_is_dropped_and_the_run_continues() {
596        let script = "echo before; printf '%05000d\\n' 7; echo after";
597        let args = vec!["-c".to_string(), script.to_string()];
598        let cancel = tokio_util::sync::CancellationToken::new();
599        let mut seen: Vec<String> = Vec::new();
600        let outcome = spawn_and_stream(
601            SpawnParams {
602                cli_label: "test",
603                binary: "sh",
604                args: &args,
605                extra_env: &HashMap::new(),
606                strip_env: &[],
607                cwd: ".",
608                max_bytes: 1024,
609                cancel: &cancel,
610            },
611            |l| seen.push(l.to_string()),
612        )
613        .await
614        .expect("an oversized line must not abort the run");
615
616        assert_eq!(seen, vec!["before".to_string(), "after".to_string()]);
617        match outcome {
618            SpawnOutcome::Done {
619                exit_code,
620                dropped_lines,
621                ..
622            } => {
623                assert_eq!(exit_code, Some(0));
624                assert_eq!(dropped_lines, 1, "the loss is counted, never silent");
625            }
626            SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
627        }
628    }
629
630    /// stderr retention is a TAIL, not the whole stream. It exists so
631    /// `extract_error_message` has the CLI's parting words; a chatty process
632    /// logging megabytes to stderr must not be held in full for that.
633    #[cfg(unix)]
634    #[tokio::test]
635    async fn stderr_retains_a_bounded_tail() {
636        // ~1 MB of filler, then the line that matters, all on stderr.
637        let script = "i=0; while [ $i -lt 10000 ]; do printf '%0100d\\n' $i 1>&2; i=$((i+1)); done; \
638             echo 'Error: the last words' 1>&2; exit 1";
639        let args = vec!["-c".to_string(), script.to_string()];
640        let cancel = tokio_util::sync::CancellationToken::new();
641        let outcome = spawn_and_stream(
642            SpawnParams {
643                cli_label: "test",
644                binary: "sh",
645                args: &args,
646                extra_env: &HashMap::new(),
647                strip_env: &[],
648                cwd: ".",
649                max_bytes: 1024,
650                cancel: &cancel,
651            },
652            |_| {},
653        )
654        .await
655        .expect("spawn should succeed");
656
657        match outcome {
658            SpawnOutcome::Done { stderr, .. } => {
659                let stderr = stderr.expect("stderr was written");
660                assert!(
661                    stderr.len() <= 256 * 1024,
662                    "stderr retention must be bounded, got {} bytes",
663                    stderr.len()
664                );
665                assert!(
666                    stderr.contains("the last words"),
667                    "the tail is the part that explains the failure"
668                );
669            }
670            SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
671        }
672    }
673}