Skip to main content

agent_abstraction/
run.rs

1//! Spawning an agent and turning its output into events and an outcome.
2//!
3//! Two entry points over the same machinery:
4//! - [`run`] waits and hands back the finished [`Outcome`].
5//! - [`stream`] hands back a [`Run`] that yields [`Event`]s as they arrive, for
6//!   a UI that shows work in progress.
7//!
8//! Both read stdout and stderr concurrently. Draining only one would deadlock
9//! the moment the other filled its pipe buffer, which for a chatty agent is a
10//! matter of seconds.
11
12use std::process::Stdio;
13
14use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
15use tokio::process::{Child, Command};
16use tokio::sync::mpsc;
17
18use crate::agent::{Continue, EnvPolicy};
19use crate::error::{Error, Result};
20use crate::event::{Event, MAX_LINE, Parser, Terminal, append_capped};
21use crate::outcome::{Outcome, Stop};
22use crate::proc::{kill_group_by_pid, kill_process_group};
23use crate::request::Request;
24
25/// Read one line, giving up on a line that never ends.
26///
27/// `AsyncBufReadExt::lines` buffers until a newline arrives, so a stream that
28/// emits megabytes without one exhausts memory before any total cap applies.
29/// This reads a bounded amount and, past the limit, returns what it has and
30/// discards the remainder of that line. Returns `None` at end of input.
31async fn read_bounded_line<R>(reader: &mut R, buf: &mut String) -> std::io::Result<Option<bool>>
32where
33    R: tokio::io::AsyncBufRead + Unpin,
34{
35    buf.clear();
36    let mut bytes = Vec::new();
37    let mut truncated = false;
38    loop {
39        let mut byte = [0u8; 1];
40        match reader.read(&mut byte).await? {
41            // End of input: a trailing fragment still counts as a line.
42            0 => {
43                if bytes.is_empty() {
44                    return Ok(None);
45                }
46                break;
47            }
48            _ if byte[0] == b'\n' => break,
49            _ => {
50                if bytes.len() < MAX_LINE {
51                    bytes.push(byte[0]);
52                } else {
53                    // Keep draining to the newline so the pipe does not block,
54                    // but stop accumulating.
55                    truncated = true;
56                }
57            }
58        }
59    }
60    // Output is not guaranteed to be valid UTF-8, and one bad byte should not
61    // end a run.
62    buf.push_str(&String::from_utf8_lossy(&bytes));
63    Ok(Some(truncated))
64}
65
66/// How many events may queue before the producer waits for the consumer. Deep
67/// enough that a burst of tool events does not stall the agent, shallow enough
68/// that a consumer which stops reading does not grow without bound.
69const EVENT_BUFFER: usize = 256;
70
71/// A run in progress.
72///
73/// Yields events through [`Run::recv`] and settles into an [`Outcome`] through
74/// [`Run::finish`].
75///
76/// **Dropping a `Run` kills the agent.** That is the safe default for the hosts
77/// this crate targets: closing a window or cancelling a request should stop the
78/// work, not leave an agent running invisibly, spending quota and touching
79/// files with nobody watching. Call [`Run::detach`] when background execution is
80/// genuinely what you want.
81///
82/// On Unix, dropping **synchronously signals** the run's process group and then
83/// aborts the driver task. What it cannot do is *wait*: `Drop` cannot await, so
84/// it does not block until the child has exited or its readers have been
85/// joined. Use [`Run::cancel`] when you need to know the tree has actually gone
86/// before continuing, such as before touching the files it was working on. On
87/// Windows only the direct child is signalled.
88#[derive(Debug)]
89pub struct Run {
90    events: mpsc::Receiver<Event>,
91    /// The typed command line, kept so both the plain and redacted views come
92    /// from the same source.
93    typed: Vec<crate::agent::Arg>,
94    /// The child's pid, so `Drop` can tear the group down itself rather than
95    /// depending on an aborted task being polled.
96    pid: Option<u32>,
97    /// Set by the driver once the child has been reaped, so `Drop` never
98    /// signals a pid the OS may since have handed to someone else.
99    reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
100    /// Dropping or firing this asks the driver to tear down in order. Held as
101    /// an `Option` so `detach` can discard it without signalling.
102    cancel: Option<tokio::sync::oneshot::Sender<()>>,
103    /// `None` only after [`Run::finish`], [`Run::cancel`] or [`Run::detach`]
104    /// has taken ownership, which is what stops `Drop` from aborting a run that
105    /// was already settled deliberately.
106    task: Option<tokio::task::JoinHandle<Result<Outcome>>>,
107    argv: Vec<String>,
108}
109
110impl Run {
111    /// The next event, or `None` once the agent has finished producing them.
112    pub async fn recv(&mut self) -> Option<Event> {
113        self.events.recv().await
114    }
115
116    /// The exact command line that was spawned.
117    ///
118    /// **This contains the prompt and any session id.** Treat it as sensitive:
119    /// logging it verbatim puts user content into your logs. Use
120    /// [`Run::redacted_argv`] for diagnostics.
121    #[must_use]
122    pub fn argv(&self) -> &[String] {
123        &self.argv
124    }
125
126    /// The command line with every non-public value replaced by a placeholder.
127    ///
128    /// Prompts, system prompts, session ids and anything from
129    /// [`crate::Request::unchecked_args`] are removed; flag names are kept so
130    /// the command stays recognisable. Sensitivity is recorded where each
131    /// argument is built rather than inferred from the finished line, so a
132    /// bare positional prompt or an opaque raw argument is covered too.
133    #[must_use]
134    pub fn redacted_argv(&self) -> Vec<String> {
135        redact(&self.typed)
136    }
137
138    /// Wait for the run to finish.
139    ///
140    /// Drains any events still queued, so a caller that only wants the result
141    /// can call this without having consumed the stream.
142    ///
143    /// # Errors
144    /// Whatever the run failed with. See [`Error`].
145    pub async fn finish(mut self) -> Result<Outcome> {
146        // The driver owns teardown from here; `Drop` must not also fire.
147        self.pid = None;
148        while self.events.recv().await.is_some() {}
149        // Taking the handle disarms the `Drop` guard: this run is settling
150        // normally, not being abandoned.
151        let Some(task) = self.task.take() else {
152            unreachable!("the handle is only taken by a consuming method")
153        };
154        match task.await {
155            Ok(result) => result,
156            // The driver task panicked or was cancelled. The process itself
157            // started fine, so this is not a spawn failure and must not claim
158            // to be one.
159            Err(join) => Err(Error::Interrupted {
160                bin: self.argv.first().cloned().unwrap_or_default(),
161                detail: if join.is_panic() {
162                    "the driver task panicked".into()
163                } else {
164                    "the driver task was cancelled".into()
165                },
166            }),
167        }
168    }
169
170    /// Stop the run and wait until the agent is actually gone.
171    ///
172    /// Cooperative rather than an abort: the driver is asked to stop, signals
173    /// the process group, reaps the child and joins its readers, and only then
174    /// does this return. So when it returns the tree really has exited, which
175    /// matters if the next thing you do touches the files it was working on.
176    ///
177    /// Returns the partial [`Outcome`] if the run happened to finish first,
178    /// otherwise [`Error::Cancelled`].
179    ///
180    /// # Errors
181    /// [`Error::Cancelled`] in the normal case, or whatever the run failed with
182    /// if it failed before the request arrived.
183    pub async fn cancel(mut self) -> Result<Outcome> {
184        // The driver tears down cooperatively and this awaits it, so `Drop`
185        // must not race that with a kill of its own.
186        self.pid = None;
187        // Dropping the sender is itself the signal, so this cannot fail in a
188        // way that leaves the driver waiting.
189        drop(self.cancel.take());
190        let Some(task) = self.task.take() else {
191            unreachable!("the handle is only taken by a consuming method")
192        };
193        match task.await {
194            Ok(result) => result,
195            Err(join) => Err(Error::Interrupted {
196                bin: self.argv.first().cloned().unwrap_or_default(),
197                detail: if join.is_panic() {
198                    "the driver task panicked".into()
199                } else {
200                    "the driver task was cancelled".into()
201                },
202            }),
203        }
204    }
205
206    /// Let the run continue after this handle goes away.
207    ///
208    /// The opposite of the default. Nothing can observe or stop the agent
209    /// afterwards, so reach for this only when an unsupervised background run
210    /// is genuinely intended.
211    pub fn detach(mut self) {
212        // Disarm `Drop` before it runs, or detaching would immediately kill the
213        // run it exists to keep alive.
214        self.pid = None;
215        // Leak the cancel signal rather than dropping it: a dropped sender is
216        // read by the driver as "stop", which is the opposite of detaching.
217        if let Some(cancel) = self.cancel.take() {
218            std::mem::forget(cancel);
219        }
220        // Dropping the handle without aborting is what detaches a tokio task.
221        drop(self.task.take());
222    }
223}
224
225impl Drop for Run {
226    fn drop(&mut self) {
227        // Abandoned rather than finished, cancelled or detached.
228        //
229        // Kill the group here, directly. Signalling the driver and aborting it
230        // is not enough on its own: that leaves teardown waiting on the runtime
231        // to poll the aborted task so its guard runs, and a dropped `Run` was
232        // observed leaving grandchildren alive and sleeping on Linux while the
233        // same teardown worked from `cancel`. `Drop` cannot await, so it does
234        // the one thing it can do synchronously.
235        if let Some(pid) = self.pid
236            && !self.reaped.load(std::sync::atomic::Ordering::SeqCst)
237        {
238            kill_group_by_pid(pid);
239        }
240        drop(self.cancel.take());
241        if let Some(task) = self.task.take() {
242            task.abort();
243        }
244    }
245}
246
247/// Placeholder substituted for a sensitive argv value.
248const REDACTED: &str = "<redacted>";
249
250/// Render a typed command line for logging, keeping flag names and replacing
251/// every value that is not `Public`.
252///
253/// Derived from the sensitivity recorded where each argument was built, so it
254/// cannot miss a case the way matching on flag names and positions can.
255fn redact(argv: &[crate::agent::Arg]) -> Vec<String> {
256    use crate::agent::Sensitivity;
257
258    argv.iter()
259        .map(|arg| match arg.sensitivity {
260            Sensitivity::Public => arg.value.clone(),
261            _ => REDACTED.to_string(),
262        })
263        .collect()
264}
265
266/// Run `request` to completion, discarding the intermediate events.
267///
268/// # Errors
269/// See [`Error`]; notably [`Error::NotInstalled`], [`Error::Timeout`],
270/// [`Error::RateLimited`] and [`Error::Failed`].
271pub async fn run(request: &Request) -> Result<Outcome> {
272    stream(request)?.finish().await
273}
274
275/// Start `request`, returning a handle that streams its events.
276///
277/// Returns as soon as the child is spawned; the work proceeds on a task.
278///
279/// # Errors
280/// [`Error::NotInstalled`] if the binary is missing, [`Error::Unsupported`] if
281/// the agent cannot honour the request, or [`Error::Spawn`] on an OS failure.
282pub fn stream(request: &Request) -> Result<Run> {
283    // `tokio::spawn` panics outside a runtime. A fallible signature must not
284    // hide that, so the context is checked and reported as an ordinary error.
285    let runtime = tokio::runtime::Handle::try_current().map_err(|_| Error::NoRuntime)?;
286
287    let plan = request.plan();
288    let typed = request.typed_argv()?;
289    let argv: Vec<String> = typed.iter().map(|a| a.value.clone()).collect();
290
291    let mut command = Command::new(&argv[0]);
292    command
293        .args(&argv[1..])
294        .stdin(if plan.stdin_prompt {
295            Stdio::piped()
296        } else {
297            // Close stdin so an agent that would otherwise wait on it exits
298            // instead of hanging forever with nothing to read.
299            Stdio::null()
300        })
301        .stdout(Stdio::piped())
302        .stderr(Stdio::piped())
303        // Without this a killed run can leave the child alive holding the pipes.
304        .kill_on_drop(true);
305    if let Some(cwd) = &request.cwd {
306        command.current_dir(cwd);
307    }
308    // Narrow the environment first, then apply explicit variables, so an
309    // explicit `env()` always wins over the policy.
310    match &request.env_policy {
311        EnvPolicy::Inherit => {}
312        EnvPolicy::Minimal => {
313            command.env_clear();
314            inherit_named(&mut command, &request.agent.essential_env());
315        }
316        EnvPolicy::Only(names) => {
317            command.env_clear();
318            inherit_named(&mut command, names);
319        }
320    }
321    for (key, value) in &request.env {
322        command.env(key, value);
323    }
324
325    // Put the agent in its own process group so the whole tree can be signalled
326    // together. Killing only the CLI leaves the commands *it* spawned running:
327    // a build, a test run, a server, still holding files and credentials after
328    // the run is supposedly over.
329    // 0 means "make this child its own group leader". `tokio::process::Command`
330    // exposes this directly on unix.
331    #[cfg(unix)]
332    command.process_group(0);
333
334    // Reserve an assigned session id before the child exists. Doing it inside
335    // the driver leaves a window where a spawn that half-succeeds loses the
336    // binding, and this is the id the caller may already be showing in a UI.
337    if let Some(token) = preassigned_token(request) {
338        persist_session(request, &token)?;
339    }
340
341    let child = command.spawn().map_err(|source| {
342        // A missing binary is the common case and deserves an actionable error
343        // with an install hint. Reading it off the spawn avoids resolving PATH
344        // twice, and with it the window where the resolved path is replaced
345        // between the check and the exec.
346        if source.kind() == std::io::ErrorKind::NotFound {
347            Error::NotInstalled {
348                agent: request.agent,
349                bin: plan.bin.clone(),
350                hint: request.agent.install_hint(),
351            }
352        } else {
353            Error::Spawn {
354                bin: plan.bin.clone(),
355                source,
356            }
357        }
358    })?;
359
360    let pid = child.id();
361    let (tx, rx) = mpsc::channel(EVENT_BUFFER);
362    let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
363    let reaped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
364    let request = request.clone();
365    let task = runtime.spawn(drive(
366        child,
367        request,
368        tx,
369        cancel_rx,
370        std::sync::Arc::clone(&reaped),
371    ));
372    Ok(Run {
373        events: rx,
374        typed,
375        pid,
376        reaped,
377        cancel: Some(cancel_tx),
378        task: Some(task),
379        argv,
380    })
381}
382
383/// Copy the named variables from this process into `command`, skipping any that
384/// are unset so nothing is invented.
385fn inherit_named<S: AsRef<str>>(command: &mut Command, names: &[S]) {
386    for name in names {
387        if let Some(value) = std::env::var_os(name.as_ref()) {
388            command.env(name.as_ref(), value);
389        }
390    }
391}
392
393/// Owns the child and tears down its whole process group when dropped.
394///
395/// `kill_on_drop` alone is not enough: it kills the CLI, leaving the commands
396/// *it* spawned running. Since aborting the driver task drops this guard, the
397/// same teardown covers cancellation, a dropped [`Run`] and a timeout, without
398/// each path having to remember to do it.
399struct ChildGuard {
400    child: Child,
401    /// Cleared once the child has been reaped, so a pid the OS may since have
402    /// recycled is never signalled.
403    armed: bool,
404}
405
406impl Drop for ChildGuard {
407    fn drop(&mut self) {
408        if self.armed {
409            kill_process_group(&self.child);
410        }
411    }
412}
413
414/// Feed the child, read both its pipes, and assemble the outcome.
415#[allow(
416    clippy::too_many_lines,
417    reason = "one linear lifecycle: feed, read, wait, classify. Splitting it \
418              would thread the child, parser, buffers and cancellation state \
419              through helpers and obscure the ordering that matters, such as \
420              killing the group before reaping."
421)]
422async fn drive(
423    child: Child,
424    request: Request,
425    events: mpsc::Sender<Event>,
426    cancel: tokio::sync::oneshot::Receiver<()>,
427    reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
428) -> Result<Outcome> {
429    // From here on the child is owned by a guard, so every exit path from this
430    // task, including an abort, takes the process group with it.
431    let mut child = ChildGuard { child, armed: true };
432    let plan = request.plan();
433    let bin = plan.bin.clone();
434
435    // Deliver a piped prompt and close the pipe, or the agent waits on EOF.
436    if plan.stdin_prompt {
437        if let Some(mut stdin) = child.child.stdin.take() {
438            let prompt = request.agent.effective_prompt(&plan);
439            stdin
440                .write_all(prompt.as_bytes())
441                .await
442                .map_err(|source| Error::Spawn {
443                    bin: bin.clone(),
444                    source,
445                })?;
446            drop(stdin);
447        }
448    }
449
450    // Drain stderr on its own task: a full stderr pipe blocks the child even
451    // while stdout still has room.
452    let stderr = child.child.stderr.take();
453    let stderr_task = tokio::spawn(async move {
454        let mut buf = String::new();
455        if let Some(handle) = stderr {
456            let mut reader = BufReader::new(handle);
457            let mut line = String::new();
458            // Keep draining after the cap is hit: an undrained pipe blocks the
459            // child even though we no longer want the bytes.
460            while let Ok(Some(_)) = read_bounded_line(&mut reader, &mut line).await {
461                append_capped(&mut buf, &line);
462            }
463        }
464        buf
465    });
466
467    let stdout = child.child.stdout.take();
468    let mut parser = Parser::new(request.agent, plan.format);
469    // Raw stdout is retained only as a fallback answer for a run that exited
470    // cleanly without producing a structured one, and as evidence when
471    // classifying a failure. It is capped for the same reason as everything
472    // else here: an agent can stream for hours.
473    let mut raw = String::new();
474    // Tracks the first `Started`, so the binding is written once, and carries a
475    // store failure back out instead of discarding it.
476    let mut bound = false;
477    let mut persist_result: Result<()> = Ok(());
478
479    let read_stdout = async {
480        if let Some(handle) = stdout {
481            let mut reader = BufReader::new(handle);
482            let mut line = String::new();
483            while read_bounded_line(&mut reader, &mut line).await?.is_some() {
484                append_capped(&mut raw, &line);
485                for event in parser.push(&line) {
486                    // Bind a printed id the moment it appears rather than at the
487                    // end. Codex announces its thread before answering, so a
488                    // turn killed mid-answer stays resumable.
489                    if let Event::Started { session, .. } = &event
490                        && !bound
491                    {
492                        bound = true;
493                        persist_result = persist_session(&request, session);
494                    }
495                    // A receiver that went away is not a failure: the run should
496                    // still finish and produce its outcome.
497                    if events.send(event).await.is_err() {
498                        break;
499                    }
500                }
501            }
502        }
503        Ok::<_, std::io::Error>(())
504    };
505
506    // Race three outcomes: the run finishing, the deadline, and a cancellation
507    // request. Reading and waiting are one future so a child that produces
508    // output forever is still bounded by the timeout.
509    let work = async {
510        read_stdout.await?;
511        child.child.wait().await
512    };
513    // A timeout is optional; `pending()` makes the un-timed case the same shape
514    // rather than duplicating the whole select.
515    let deadline = async {
516        match request.timeout {
517            Some(limit) => tokio::time::sleep(limit).await,
518            None => std::future::pending().await,
519        }
520    };
521
522    let status = tokio::select! {
523        // Biased so a finished run is reported as finished even if a deadline
524        // or cancellation lands in the same tick.
525        biased;
526        result = work => result,
527        () = deadline => {
528            // Order matters: signal the group *before* reaping. Reaping clears
529            // the child's pid, and the group kill needs that pid to target the
530            // group, so the other order silently leaves grandchildren running.
531            let partial = shut_down(&mut child, stderr_task).await;
532            reaped.store(true, std::sync::atomic::Ordering::SeqCst);
533            return Err(Error::Timeout {
534                bin,
535                timeout: request.timeout.unwrap_or_default(),
536                partial: parser.finish().text,
537            })
538            .inspect_err(|_| drop(partial));
539        }
540        _ = cancel => {
541            // Cooperative teardown: the caller is waiting on this, so the tree
542            // is signalled, reaped and joined before returning.
543            shut_down(&mut child, stderr_task).await;
544            reaped.store(true, std::sync::atomic::Ordering::SeqCst);
545            return Err(Error::Cancelled { bin });
546        }
547    }
548    .map_err(|source| Error::Spawn {
549        bin: bin.clone(),
550        source,
551    })?;
552
553    // The child has been reaped, so its pid must not be signalled again, by the
554    // guard here or by `Run::drop` racing this.
555    child.armed = false;
556    reaped.store(true, std::sync::atomic::Ordering::SeqCst);
557
558    drop(events);
559    let stderr = stderr_task.await.unwrap_or_default();
560    let saw_structured = parser.saw_structured_record();
561    let saw_terminal = parser.saw_terminal_record();
562    let terminal = parser.finish();
563    let exit_code = status.code().unwrap_or(-1);
564
565    // Under a structured format, silently handing back raw stdout would turn a
566    // protocol failure into a plausible-looking answer. A run that recognized
567    // nothing, or never reached its terminal record, did not produce a result
568    // this crate can vouch for, so it is reported rather than papered over.
569    let structured = plan.format != crate::Format::Text;
570    if structured && exit_code == 0 {
571        if !saw_structured {
572            return Err(Error::Parse {
573                agent: request.agent,
574                detail: format!(
575                    "no recognizable {} records in {} lines of output;                      the CLI's output shape has probably changed",
576                    request.agent,
577                    raw.lines().count()
578                ),
579            });
580        }
581        if !saw_terminal {
582            return Err(Error::Parse {
583                agent: request.agent,
584                detail: "the stream ended without its terminal record, so the turn                          did not complete"
585                    .into(),
586            });
587        }
588    }
589
590    // Plain text has no structure to validate: the stream is the answer.
591    let mut terminal = terminal;
592    if terminal.text.is_empty() && !structured {
593        terminal.text = raw.trim().to_string();
594    }
595
596    // A provider refusal is not always an exit code. Claude can report a
597    // blocking `rate_limit_event` and still exit 0, and the crate promises that
598    // quota refusals surface as `Error::RateLimited`, so the terminal state is
599    // checked regardless of how the process exited.
600    let quota_blocked = terminal
601        .rate_limit
602        .as_ref()
603        .is_some_and(crate::outcome::RateLimit::is_blocking);
604    // An unauthenticated Claude run exits 0 and reports the problem in its
605    // result text, so checking only the exit code would hand back a successful
606    // Outcome whose answer is "Please run /login".
607    let unauthenticated = looks_unauthenticated(&terminal.text);
608    if exit_code != 0 || quota_blocked || unauthenticated {
609        return Err(classify_run(
610            request.agent,
611            &bin,
612            exit_code,
613            &stderr,
614            &raw,
615            &terminal,
616        ));
617    }
618
619    // A fork lands on a *new* id the agent only reveals at the end, so the name
620    // has to be repointed once the run settles. Everything else was bound above.
621    persist_result?;
622    if let Some(token) = &terminal.session
623        && !bound
624    {
625        persist_session(&request, token)?;
626    }
627    Ok(Outcome {
628        agent: request.agent,
629        session: terminal.session,
630        text: terminal.text,
631        usage: terminal.usage,
632        stop: terminal.stop,
633        rate_limit: terminal.rate_limit,
634        exit_code,
635        stderr,
636        unparsed: terminal.unparsed,
637        first_unparsed: terminal.first_unparsed,
638    })
639}
640
641/// Kill the process group, reap the child, and join the stderr reader.
642///
643/// The orderly teardown both cancellation and timeout share. Returns whatever
644/// stderr had been captured, so a caller can still report why a run was stopped.
645async fn shut_down(child: &mut ChildGuard, stderr_task: tokio::task::JoinHandle<String>) -> String {
646    kill_process_group(&child.child);
647    // Reap, so the caller is not left with a zombie once this returns.
648    let _ = child.child.kill().await;
649    child.armed = false;
650    // The pipes are closed now that the child is gone, so this finishes
651    // promptly rather than hanging the cancellation.
652    stderr_task.await.unwrap_or_default()
653}
654
655/// Turn a failure into the most specific error available, agent included so an
656/// auth failure can carry the right login command.
657fn classify_run(
658    agent: crate::Agent,
659    bin: &str,
660    code: i32,
661    stderr: &str,
662    stdout: &str,
663    terminal: &Terminal,
664) -> Error {
665    // Checked before quota and before a plain failure: a login problem is the
666    // most specific reading of the output, and the only one a user can act on
667    // directly.
668    for source in [terminal.text.as_str(), stderr, stdout] {
669        if looks_unauthenticated(source) {
670            return Error::NotAuthenticated {
671                agent,
672                bin: bin.to_string(),
673                message: first_meaningful_line(source).unwrap_or_default(),
674                hint: agent.login_hint(),
675            };
676        }
677    }
678    classify(bin, code, stderr, stdout, terminal)
679}
680
681/// Whether text is an agent saying it has no usable credentials.
682///
683/// Narrow on purpose. Mislabelling an ordinary failure as an auth problem sends
684/// someone to re-login over something unrelated, so these are phrases the CLIs
685/// actually emit rather than every string containing "auth".
686fn looks_unauthenticated(text: &str) -> bool {
687    const PHRASES: &[&str] = &[
688        // Claude, verified: an unauthenticated run answers exactly this.
689        "not logged in",
690        "please run /login",
691        "invalid api key",
692        "authentication_error",
693        "unauthorized",
694        "not authenticated",
695        "no credentials",
696        "credentials not found",
697        "please log in",
698        "401",
699    ];
700    let lower = text.to_ascii_lowercase();
701    PHRASES.iter().any(|needle| lower.contains(needle))
702}
703
704/// Turn a non-zero exit into the most specific error available.
705fn classify(bin: &str, code: i32, stderr: &str, stdout: &str, terminal: &Terminal) -> Error {
706    let quota_signalled = terminal
707        .rate_limit
708        .as_ref()
709        .is_some_and(crate::outcome::RateLimit::is_blocking);
710    if quota_signalled || looks_rate_limited(stderr) || looks_rate_limited(stdout) {
711        return Error::RateLimited {
712            bin: bin.to_string(),
713            message: first_meaningful_line(stderr)
714                .or_else(|| first_meaningful_line(stdout))
715                .unwrap_or_else(|| "usage limit reached".to_string()),
716        };
717    }
718    // A rejected flag is not a failed request, it is this crate and the CLI
719    // disagreeing about what the CLI accepts. Naming that is the difference
720    // between "the run failed" and "your codex is a different version".
721    if let Some(detail) = rejected_flag(stderr).or_else(|| rejected_flag(stdout)) {
722        return Error::FlagRejected {
723            bin: bin.to_string(),
724            detail,
725        };
726    }
727    Error::Failed {
728        bin: bin.to_string(),
729        code,
730        stderr: first_meaningful_line(stderr).unwrap_or_default(),
731    }
732}
733
734/// The CLI's complaint, if it refused an argument.
735///
736/// The phrasings are clap's and commander's, which is what all three CLIs are
737/// built on. Matched narrowly: a false positive would relabel a genuine failure
738/// as a version problem and send someone chasing the wrong thing.
739fn rejected_flag(text: &str) -> Option<String> {
740    const REJECTIONS: &[&str] = &[
741        "unexpected argument",
742        "unknown option",
743        "unrecognized option",
744        "unknown flag",
745        "invalid option",
746        "unexpected option",
747    ];
748    let lower = text.to_ascii_lowercase();
749    REJECTIONS
750        .iter()
751        .any(|needle| lower.contains(needle))
752        .then(|| first_meaningful_line(text).unwrap_or_default())
753}
754
755/// Whether text carries a provider quota refusal.
756///
757/// Deliberately a small set of unambiguous phrases: a false positive here would
758/// relabel an ordinary failure as a quota problem and send a caller into a
759/// pointless backoff.
760fn looks_rate_limited(text: &str) -> bool {
761    let lower = text.to_ascii_lowercase();
762    [
763        "rate limit",
764        "rate_limit",
765        "usage limit",
766        "quota exceeded",
767        "too many requests",
768        "429",
769    ]
770    .iter()
771    .any(|needle| lower.contains(needle))
772}
773
774/// The first non-blank line, trimmed. Enough to identify a failure without
775/// pasting an entire stack trace into an error message.
776fn first_meaningful_line(text: &str) -> Option<String> {
777    text.lines()
778        .map(str::trim)
779        .find(|line| !line.is_empty())
780        .map(str::to_string)
781}
782
783/// Write the session binding back, reporting any store failure.
784///
785/// Called as soon as an id is known rather than only on a clean exit. Waiting
786/// for success would lose the binding for exactly the runs where continuity
787/// matters most: a timeout, a crash, or a cancelled turn.
788fn persist_session(request: &Request, token: &str) -> Result<()> {
789    let Some(binding) = &request.binding else {
790        return Ok(());
791    };
792    binding
793        .store
794        .bind(request.agent, &binding.project, &binding.name, token)
795        .map(|_| ())
796}
797
798/// The id this run is already known by before it starts, if any.
799///
800/// Only a caller-assigned id qualifies: a printed id does not exist yet. This
801/// is what makes an assigned session survive a run that never finishes.
802fn preassigned_token(request: &Request) -> Option<String> {
803    match &request.plan().cont {
804        Continue::NewWith(id) => Some(id.clone()),
805        _ => None,
806    }
807}
808
809/// Reported by an agent that exited cleanly but said nothing useful.
810impl Outcome {
811    /// Whether the agent produced any answer at all.
812    #[must_use]
813    pub fn is_empty(&self) -> bool {
814        self.text.trim().is_empty() && self.stop == Stop::Completed
815    }
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821    use crate::agent::Agent;
822
823    #[test]
824    fn quota_phrases_are_recognized_and_ordinary_errors_are_not() {
825        assert!(looks_rate_limited("Error: rate limit exceeded"));
826        assert!(looks_rate_limited("HTTP 429 Too Many Requests"));
827        assert!(looks_rate_limited("You have hit your usage limit"));
828        // A plain failure must not be mistaken for a quota problem.
829        assert!(!looks_rate_limited("error: no such file or directory"));
830        assert!(!looks_rate_limited("model not found"));
831    }
832
833    #[test]
834    fn a_blocking_rate_limit_event_classifies_as_rate_limited() {
835        let terminal = Terminal {
836            rate_limit: Some(crate::outcome::RateLimit {
837                status: "rejected".into(),
838                window: Some("five_hour".into()),
839                resets_at: None,
840            }),
841            ..Terminal::default()
842        };
843        assert!(matches!(
844            classify("claude", 1, "", "", &terminal),
845            Error::RateLimited { .. }
846        ));
847    }
848
849    #[test]
850    fn an_allowed_rate_limit_event_is_not_a_failure_cause() {
851        let terminal = Terminal {
852            rate_limit: Some(crate::outcome::RateLimit {
853                status: "allowed".into(),
854                window: None,
855                resets_at: None,
856            }),
857            ..Terminal::default()
858        };
859        assert!(matches!(
860            classify("claude", 1, "boom", "", &terminal),
861            Error::Failed { .. }
862        ));
863    }
864
865    /// Verified against the real CLI: with `USER` withheld, claude answers
866    /// "Not logged in · Please run /login" and exits **0**. Checking only the
867    /// exit code hands back a successful Outcome whose answer is a login
868    /// prompt.
869    #[test]
870    fn an_unauthenticated_run_is_named_even_though_it_exits_zero() {
871        let terminal = Terminal {
872            text: "Not logged in · Please run /login".into(),
873            ..Terminal::default()
874        };
875        let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
876        let Error::NotAuthenticated { agent, hint, .. } = &err else {
877            panic!("expected NotAuthenticated, got {err:?}")
878        };
879        assert_eq!(*agent, Agent::Claude);
880        assert!(hint.contains("/login"), "{hint}");
881        assert!(err.is_auth_failure());
882    }
883
884    /// Each agent's hint has to name its own login route, since they differ:
885    /// Codex and Copilot have `login` subcommands, Claude does not.
886    #[test]
887    fn every_agent_offers_its_own_login_route() {
888        for (agent, expected) in [
889            (Agent::Claude, "setup-token"),
890            (Agent::Codex, "codex login"),
891            (Agent::Copilot, "copilot login"),
892        ] {
893            let err = classify_run(
894                agent,
895                agent.bin(),
896                1,
897                "error: unauthorized",
898                "",
899                &Terminal::default(),
900            );
901            let Error::NotAuthenticated { hint, .. } = &err else {
902                panic!("{agent}: expected NotAuthenticated, got {err:?}")
903            };
904            assert!(hint.contains(expected), "{agent}: {hint}");
905        }
906    }
907
908    /// Auth is the most specific reading, so it wins over a generic failure,
909    /// but must not swallow unrelated errors.
910    #[test]
911    fn ordinary_failures_are_not_mistaken_for_auth_problems() {
912        for stderr in [
913            "error: no such file or directory",
914            "model not found",
915            "rate limit exceeded",
916            "error: unexpected argument '--sandbox' found",
917        ] {
918            let err = classify_run(Agent::Codex, "codex", 1, stderr, "", &Terminal::default());
919            assert!(
920                !err.is_auth_failure(),
921                "{stderr:?} was misread as an auth failure: {err:?}"
922            );
923        }
924    }
925
926    /// The exact failure that cost a round of debugging: `codex exec resume`
927    /// rejects `--sandbox`, which `Error::Failed` reported as a generic
928    /// non-zero exit naming a flag rather than a version mismatch.
929    #[test]
930    fn a_rejected_flag_is_named_as_a_version_mismatch() {
931        let err = classify(
932            "codex",
933            2,
934            "error: unexpected argument '--sandbox' found",
935            "",
936            &Terminal::default(),
937        );
938        let Error::FlagRejected { bin, detail } = err else {
939            panic!("expected FlagRejected, got {err:?}")
940        };
941        assert_eq!(bin, "codex");
942        assert!(detail.contains("--sandbox"), "{detail}");
943    }
944
945    #[test]
946    fn ordinary_failures_are_not_mistaken_for_version_drift() {
947        for stderr in [
948            "error: no such file or directory",
949            "model not found",
950            "permission denied",
951        ] {
952            assert!(
953                matches!(
954                    classify("codex", 1, stderr, "", &Terminal::default()),
955                    Error::Failed { .. }
956                ),
957                "{stderr:?} should stay a plain failure"
958            );
959        }
960    }
961
962    #[test]
963    fn failures_report_the_first_useful_line() {
964        let err = classify(
965            "claude",
966            2,
967            "\n\n  real problem  \nstack",
968            "",
969            &Terminal::default(),
970        );
971        let Error::Failed { code, stderr, .. } = err else {
972            panic!("expected a plain failure")
973        };
974        assert_eq!(code, 2);
975        assert_eq!(stderr, "real problem");
976    }
977
978    /// Prompts and session ids ride the argv, and `Run::argv` invites logging
979    /// it. The redacted form must keep the shape while dropping the content.
980    #[test]
981    fn redaction_removes_prompts_and_session_ids_but_keeps_flags() {
982        let request = crate::Request::new(Agent::Claude, "my secret prompt")
983            .system("secret system")
984            .session_id("11111111-2222-3333-4444-555555555555");
985        let safe = redact(&request.typed_argv().unwrap());
986
987        for secret in [
988            "my secret prompt",
989            "secret system",
990            "11111111-2222-3333-4444-555555555555",
991        ] {
992            assert!(
993                !safe.iter().any(|a| a.contains(secret)),
994                "{secret:?} survived redaction: {safe:?}"
995            );
996        }
997        // Still recognisable as the same command.
998        assert_eq!(safe[0], "claude");
999        assert!(safe.contains(&"--permission-mode".to_string()));
1000        assert!(safe.contains(&"--session-id".to_string()));
1001    }
1002
1003    #[test]
1004    fn codex_trailing_prompt_is_redacted_even_without_a_flag() {
1005        let request = crate::Request::new(Agent::Codex, "my secret prompt");
1006        let safe = redact(&request.typed_argv().unwrap());
1007        assert_eq!(safe.last().unwrap(), REDACTED);
1008        assert_eq!(safe[1], "exec", "the subcommand must survive");
1009    }
1010
1011    /// Redaction must cover the two shapes positional guesswork misses: Codex's
1012    /// bare trailing prompt, and raw arguments whose contents are unknowable.
1013    #[test]
1014    fn redaction_covers_positional_prompts_and_unchecked_arguments() {
1015        let request = crate::Request::new(Agent::Codex, "my secret prompt")
1016            .unchecked_args(["-c", "api_key=hunter2"]);
1017        let safe = redact(&request.typed_argv().unwrap());
1018        assert!(!safe.iter().any(|a| a.contains("my secret prompt")));
1019        assert!(
1020            !safe.iter().any(|a| a.contains("hunter2")),
1021            "unchecked arguments may hold secrets: {safe:?}"
1022        );
1023        assert_eq!(safe[1], "exec", "the subcommand must survive");
1024    }
1025
1026    /// A resume id is a capability: it continues someone's conversation.
1027    #[test]
1028    fn redaction_covers_the_codex_positional_resume_id() {
1029        let request = crate::Request::new(Agent::Codex, "hi").resume("thread-secret-9");
1030        let safe = redact(&request.typed_argv().unwrap());
1031        assert!(
1032            !safe.iter().any(|a| a.contains("thread-secret-9")),
1033            "{safe:?}"
1034        );
1035        assert!(safe.contains(&"resume".to_string()));
1036    }
1037
1038    /// `stream` is synchronous but spawns a task. Outside a runtime that would
1039    /// panic, which a `Result`-returning function must not do.
1040    #[test]
1041    fn stream_outside_a_runtime_errors_instead_of_panicking() {
1042        let err = stream(&crate::Request::new(Agent::Claude, "hi")).unwrap_err();
1043        assert!(matches!(err, Error::NoRuntime), "got {err:?}");
1044    }
1045
1046    #[tokio::test]
1047    async fn a_missing_binary_names_the_install_command() {
1048        let request = Request::new(Agent::Claude, "hi").bin("definitely-not-a-real-binary-xyz");
1049        let err = run(&request).await.unwrap_err();
1050        let Error::NotInstalled { hint, agent, .. } = err else {
1051            panic!("expected NotInstalled, got {err:?}")
1052        };
1053        assert_eq!(agent, Agent::Claude);
1054        assert!(hint.contains("claude-code"));
1055    }
1056
1057    #[test]
1058    fn transient_errors_are_distinguished_from_permanent_ones() {
1059        assert!(
1060            Error::RateLimited {
1061                bin: "claude".into(),
1062                message: String::new()
1063            }
1064            .is_transient()
1065        );
1066        assert!(
1067            !Error::NotInstalled {
1068                agent: Agent::Claude,
1069                bin: "claude".into(),
1070                hint: ""
1071            }
1072            .is_transient()
1073        );
1074    }
1075}