Skip to main content

agentmux_srv/agents/
runner.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Unified agent runner — one-shot Claude Code spawn for the
5//! drone Agent block.
6//!
7//! Spawns `claude --print --output-format=stream-json` as a non-
8//! interactive subprocess, drains its stdout through
9//! `ClaudeTranslator`, forwards each `AgentEvent` on the caller's
10//! `tx`, and resolves the handle's `final_result` with the
11//! structured `AgentRunResult` once the stream emits `Done`.
12//!
13//! Headless and one-shot by design — the drone Agent block's
14//! contract is "send task, wait for done, return result." The
15//! interactive agent pane has its own PTY-based controller in
16//! `blockcontroller/shell/lifecycle.rs`; that path is NOT routed
17//! through this runner's spawn — what's shared is the translator +
18//! event shape, not the spawn function. (This used to cite a
19//! `SPEC_UNIFIED_AGENT_TYPES` spec that no longer exists anywhere in
20//! `docs/`; the pointer was dropped rather than replaced — see
21//! `scripts/check-spec-citations.sh`.)
22//! It DOES reuse this module's commit-aware admission gate
23//! (`admit_spawn` / `agent_commit_reserve_gb`, `pub(crate)`) — see
24//! `blockcontroller::shell::lifecycle::ShellController::start`,
25//! which calls them right before spawning a `claude`/`codex`/
26//! `gemini`/`qwen` interactive pane, mirroring this file's
27//! `run_agent` gate.
28
29#[cfg(windows)]
30use agentmux_common::win32::NoWindow;
31use std::path::PathBuf;
32use std::process::Stdio;
33
34use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
35use tokio::process::Command;
36use tokio::sync::{mpsc, oneshot};
37
38use super::failure::classify;
39use super::translator::claude::ClaudeTranslator;
40use super::translator::Translator as _;
41use super::types::{AgentEvent, AgentRef, AgentRunResult, AgentTask};
42
43/// Override the default `claude` binary name. Set to a full path
44/// (or a different binary) for testing or non-PATH installs.
45const ENV_CLAUDE_BIN: &str = "AGENTMUX_CLAUDE_BIN";
46
47const DEFAULT_CLAUDE_BIN: &str = "claude";
48
49/// Handle returned by `run_agent`. The caller already holds the
50/// `mpsc::UnboundedReceiver<AgentEvent>` they paired with the `tx`
51/// passed into `run_agent`; this handle adds the structured terminal
52/// value via `final_result` (drone Agent block's downstream
53/// output).
54///
55/// Dropping the caller's receiver implicitly cancels the run only if
56/// the runner observes the send error — Phase 2 adds an explicit
57/// `AbortHandle`.
58pub struct AgentRunHandle {
59    pub final_result: oneshot::Receiver<Result<AgentRunResult, String>>,
60}
61
62/// Error returned by the runner.
63#[derive(Debug, thiserror::Error)]
64pub enum AgentError {
65    #[error("agent runner: spawn failed: {0}")]
66    Spawn(String),
67    /// System commit headroom is below the reserve required to safely start
68    /// another agent. Refusing here prevents a new `claude.exe` from tipping the
69    /// system commit charge into its limit, where a failed allocation aborts the
70    /// CEF host (Chromium OOM, `0xE0000008`). Callers should surface a transient
71    /// "memory full — try again when memory frees" rather than a hard failure.
72    /// Pillar 3 (`SPEC_WIN10_PAGEFILE_OOM_CRASH` / `SPEC_ARCHITECTURE_HEALTH_AND_REFACTOR`).
73    #[error("agent runner: insufficient memory to start agent ({avail_gb:.1} GB commit free, need {reserve_gb:.1} GB headroom)")]
74    CommitPressure { avail_gb: f64, reserve_gb: f64 },
75}
76
77/// Minimum free system commit (GB) required to admit a new agent spawn. Below
78/// this, launching another `claude.exe` risks pushing the system commit charge
79/// to its limit, where a failed allocation aborts the CEF host (Chromium OOM).
80///
81/// SPEC_WIN10_PAGEFILE_OOM_CRASH_2026_06_29's original P0 called for
82/// re-deriving this reserve from real per-agent `PrivateUsage`, not
83/// `VirtualMemorySize64` — the 6/26 estimate (`SPEC_MEMORY_ANALYSIS_2026_06_26`)
84/// had mistakenly attributed ~10.5 GB of commit to each `claude.exe`, read off
85/// `VirtualMemorySize64` (reserved address space, not commit charge). That
86/// number was never wired into this constant — this file has measured
87/// `2.0 GB` since Pillar 3 shipped (#1853) — but the measurement trail behind
88/// it is worth stating explicitly now that it exists:
89/// `SPEC_MEMORY_COMMIT_ATTRIBUTION_CORRECTION_2026_07_02.md` re-measured a
90/// live `claude.exe`'s actual `PrivateUsage` (Windows' real per-process commit
91/// counter) at ~1.05 GB, corroborated independently by
92/// Resource-Exhaustion-Detector telemetry (0.49–0.67 GB) in the 6/29 spec.
93/// `2.0` GB is ~2x that per-agent figure, a reasonable safety margin for one
94/// additional spawn — no VirtualMemorySize64-derived inflation to correct.
95/// Overridable via `AGENTMUX_AGENT_COMMIT_RESERVE_GB` (e.g. 0 disables the
96/// gate on a host with a huge page file; higher on a constrained box).
97const DEFAULT_AGENT_COMMIT_RESERVE_GB: f64 = 2.0;
98
99pub(crate) fn agent_commit_reserve_gb() -> f64 {
100    std::env::var("AGENTMUX_AGENT_COMMIT_RESERVE_GB")
101        .ok()
102        .and_then(|s| s.parse::<f64>().ok())
103        .filter(|v| *v >= 0.0)
104        .unwrap_or(DEFAULT_AGENT_COMMIT_RESERVE_GB)
105}
106
107/// Pure admission decision: is there enough free system commit to spawn another
108/// agent? `None` available (non-Windows, or the read failed) ⇒ admit — there's no
109/// cheap commit limit to enforce. Strict `<` so a value exactly at the reserve is
110/// admitted. CEF-free / OS-free so it is fully unit-testable.
111pub(crate) fn admit_spawn(avail_commit_gb: Option<f64>, reserve_gb: f64) -> Result<(), AgentError> {
112    match avail_commit_gb {
113        Some(avail) if avail < reserve_gb => {
114            Err(AgentError::CommitPressure { avail_gb: avail, reserve_gb })
115        }
116        _ => Ok(()),
117    }
118}
119
120/// Spawn `claude --print --output-format=stream-json` per the given
121/// `AgentTask` and `AgentRef`, drain its stdout through the shared
122/// `ClaudeTranslator`, forward each `AgentEvent` on `tx`, and
123/// resolve the returned handle's `final_result` with an
124/// `AgentRunResult` constructed from the terminal Cost + Done events.
125///
126/// Working directory resolution:
127///   - `agent_ref.working_directory` if non-empty
128///   - else the current process working directory
129///
130/// Account and bundle resolution, and named-agent continuation
131/// are NOT plumbed in Phase 1.5 PR 2 — the drone Agent block
132/// always spawns fresh (per spec §8 "drone runs always allocate
133/// fresh instance_name"). The bundles can be added in a follow-up
134/// once the drone inspector (PR 3) needs to surface them.
135pub async fn run_agent(
136    agent_ref: AgentRef,
137    task: AgentTask,
138    tx: mpsc::UnboundedSender<AgentEvent>,
139) -> Result<AgentRunHandle, AgentError> {
140    // Pillar 3 — commit-aware admission control. Refuse to spawn another agent
141    // when system commit headroom is below the reserve, rather than letting the
142    // new claude.exe push the box into an OOM abort. Gated here (the production
143    // entry) not in `run_agent_with_bin`, so the unit tests that inject a binary
144    // path bypass the gate and stay deterministic across hosts.
145    admit_spawn(
146        crate::backend::sysinfo::available_commit_gb(),
147        agent_commit_reserve_gb(),
148    )?;
149    let bin = std::env::var(ENV_CLAUDE_BIN)
150        .unwrap_or_else(|_| DEFAULT_CLAUDE_BIN.to_string());
151    run_agent_with_bin(&bin, agent_ref, task, tx).await
152}
153
154/// Internal entry point — same as `run_agent` but takes the `claude`
155/// binary path explicitly. Lets tests inject a known-nonexistent
156/// path to exercise the spawn-failure path without touching env vars
157/// (Rust 1.81+ flags `std::env::set_var` as unsound under concurrent
158/// test execution). The public `run_agent` is a thin shim that
159/// resolves the binary from `$AGENTMUX_CLAUDE_BIN` or the default.
160pub(crate) async fn run_agent_with_bin(
161    bin: &str,
162    agent_ref: AgentRef,
163    task: AgentTask,
164    tx: mpsc::UnboundedSender<AgentEvent>,
165) -> Result<AgentRunHandle, AgentError> {
166    let working_dir = if agent_ref.working_directory.is_empty() {
167        std::env::current_dir()
168            .map_err(|e| AgentError::Spawn(format!("cwd: {e}")))?
169    } else {
170        PathBuf::from(&agent_ref.working_directory)
171    };
172
173    // `claude --print` runs in non-interactive mode and exits when
174    // done. `--output-format=stream-json` emits one JSON object per
175    // line, the format ClaudeTranslator consumes. `--verbose` is
176    // required alongside stream-json (the CLI rejects stream-json
177    // without it). `--include-partial-messages` gives us the
178    // streaming text_deltas — the translator skips the resulting
179    // `partial: true` snapshots when building the transcript.
180    let mut cmd = Command::new(bin);
181    // The drone one-shot agent spawn — sibling of the interactive agent pane in
182    // blockcontroller. `tokio::process::Command` inherits the parent environment
183    // wholesale, so without this every drone-launched CLI (and everything it
184    // shells out to) receives this instance's channel, data dir and cache dir.
185    // Same policy as the agent pane: the CLI is one of ours and may use the
186    // in-pane helpers, so the keep-set applies. (ReAgent P0, round 3, on #3326.)
187    crate::backend::pane_env::sanitize_process_command(&mut cmd);
188    cmd.arg("--print")
189        .arg("--output-format=stream-json")
190        .arg("--verbose")
191        .arg("--include-partial-messages")
192        // Moves per-machine sections (cwd, env info, memory paths, git status)
193        // out of the system prompt into the first user message, matching the
194        // persistent-controller launch args in providers.rs. This spawn path
195        // builds its own args independent of ProviderConfig, so the flag has
196        // to be added here too — reagent P1 on PR #1964.
197        .arg("--exclude-dynamic-system-prompt-sections");
198    // Forward the configured turn cap so the CLI enforces it.
199    // Previously stored on AgentTask but never passed to the
200    // subprocess — silently ignored. Reagent P1 + codex P2 on
201    // PR #834.
202    if let Some(n) = task.max_turns {
203        cmd.arg("--max-turns").arg(n.to_string());
204    }
205    // On Windows: suppress console-window allocation. Spawned from the windowless
206    // srv without CREATE_NO_WINDOW, this one-shot task-agent CLI opens a Windows
207    // Terminal window per run (Win11 default-terminal handler). stdio is piped, so
208    // no console is needed. See docs/retro/retro-windows-terminal-window-leak-2026-06-21.md.
209    #[cfg(windows)]
210    {
211        cmd.no_window();
212    }
213    let mut child = cmd
214        .arg(&task.prompt)
215        .current_dir(&working_dir)
216        .stdin(Stdio::null())
217        .stdout(Stdio::piped())
218        .stderr(Stdio::piped())
219        .kill_on_drop(true)
220        .spawn()
221        .map_err(|e| AgentError::Spawn(format!("spawn `{bin}`: {e}")))?;
222
223    let stdout = child
224        .stdout
225        .take()
226        .ok_or_else(|| AgentError::Spawn("claude stdout pipe missing".to_string()))?;
227    let stderr = child
228        .stderr
229        .take()
230        .ok_or_else(|| AgentError::Spawn("claude stderr pipe missing".to_string()))?;
231
232    let (result_tx, result_rx) = oneshot::channel();
233    // Hand the captured stderr back to `drain_and_collect` so a failed
234    // run reports the real cause (rate-limit / auth / OOM) instead of a
235    // bare exit code. See
236    // `docs/specs/SPEC_AGENT_FAILURE_DIAGNOSTICS_2026_06_11.md`.
237    let (stderr_tx, stderr_rx) = oneshot::channel::<Vec<u8>>();
238
239    // Drain stderr to EOF in the background so the child's pipe never
240    // fills (a half-drained pipe blocks the child on stderr writes and
241    // can stall the whole run). We keep a rolling *tail* — the last
242    // STDERR_TAIL_CAP bytes — because the CLI's terminal error line
243    // (rate-limit / auth / OOM) lands at the END of stderr; a capped
244    // prefix would drop exactly the line the classifier needs. On EOF
245    // the tail is handed to the collector via `stderr_tx`.
246    // (codex P2 on #1353: keep the tail, not a prefix.)
247    tokio::spawn(async move {
248        const STDERR_TAIL_CAP: usize = 64 * 1024;
249        let mut buf: Vec<u8> = Vec::with_capacity(8192);
250        let mut reader = BufReader::new(stderr);
251        let mut sink = [0u8; 8192];
252        loop {
253            match reader.read(&mut sink).await {
254                Ok(0) => break,
255                Ok(n) => append_capped_tail(&mut buf, &sink[..n], STDERR_TAIL_CAP),
256                Err(_) => break,
257            }
258        }
259        // Trim to exactly the last CAP bytes. If the receiver is gone
260        // (run succeeded — nobody needs stderr), the send just drops.
261        trim_to_tail(&mut buf, STDERR_TAIL_CAP);
262        let _ = stderr_tx.send(buf);
263    });
264
265    tokio::spawn(async move {
266        let result = drain_and_collect(stdout, &tx, &mut child, stderr_rx).await;
267        let _ = result_tx.send(result);
268    });
269
270    Ok(AgentRunHandle {
271        final_result: result_rx,
272    })
273}
274
275/// Drain `stdout` line-by-line through `ClaudeTranslator`, forward
276/// every emitted event on `tx`, accumulate the terminal `Cost` /
277/// `Done` event payloads into an `AgentRunResult`, wait for the
278/// child to exit, and return the result.
279///
280/// Split out from `run_agent`; its parsing core, `drain_async_reader`,
281/// takes any async reader, so the unit tests below exercise it against
282/// in-memory pipes without spawning a real subprocess.
283async fn drain_and_collect(
284    stdout: tokio::process::ChildStdout,
285    tx: &mpsc::UnboundedSender<AgentEvent>,
286    child: &mut tokio::process::Child,
287    stderr_rx: oneshot::Receiver<Vec<u8>>,
288) -> Result<AgentRunResult, String> {
289    let result = drain_async_reader(BufReader::new(stdout), tx).await;
290
291    // Wait for child to exit so the OS reaps it cleanly.
292    let exit = child.wait().await.map_err(|e| format!("wait: {e}"))?;
293
294    match result {
295        Ok(mut accumulated) => {
296            // A run is a failure if the process exited non-zero OR claude
297            // reported an error on stdout (a terminal error `result`
298            // frame) even while exiting 0 — otherwise downstream blocks
299            // could treat a failed run as successful. (codex P1 #1353.)
300            let reported_error = accumulated.error_frame.take();
301            if exit.success() && reported_error.is_none() {
302                // Genuine success — but a stream that produced nothing is
303                // itself a (classified) failure, not a silent empty result.
304                if accumulated.response.is_empty() && accumulated.transcript.is_empty() {
305                    return Err(
306                        explain_failure(exit.code(), exit_signal(&exit), None, stderr_rx).await,
307                    );
308                }
309                accumulated.transcript.shrink_to_fit();
310                return Ok(accumulated);
311            }
312            // Non-zero exit, or a stdout-reported error on exit 0: classify
313            // from the exit status + result frame + captured stderr so the
314            // caller sees a real cause, not "exited with status N".
315            Err(explain_failure(exit.code(), exit_signal(&exit), reported_error, stderr_rx).await)
316        }
317        // Stream read error: still enrich with the exit/stderr cause.
318        Err(e) => {
319            let cause = explain_failure(exit.code(), exit_signal(&exit), None, stderr_rx).await;
320            Err(format!("{cause}\n(stream read: {e})"))
321        }
322    }
323}
324
325/// Await the captured stderr, classify the exit, log a warning, and
326/// render the human-readable explanation that becomes the run's
327/// terminal error string. See
328/// `docs/specs/SPEC_AGENT_FAILURE_DIAGNOSTICS_2026_06_11.md`.
329async fn explain_failure(
330    exit_code: Option<i32>,
331    signal: Option<i32>,
332    result_frame: Option<serde_json::Value>,
333    stderr_rx: oneshot::Receiver<Vec<u8>>,
334) -> String {
335    let bytes = stderr_rx.await.unwrap_or_default();
336    let stderr = String::from_utf8_lossy(&bytes);
337    let failure = classify(exit_code, signal, &stderr, result_frame.as_ref());
338    tracing::warn!(
339        code = ?failure.code,
340        exit_code = ?exit_code,
341        signal = ?signal,
342        retryable = failure.retryable,
343        "agent run failed: {}",
344        failure.title,
345    );
346    failure.explain()
347}
348
349/// Extract the terminating signal (Unix only). On non-Unix there is no
350/// signal concept, so this is always `None`.
351#[cfg(unix)]
352fn exit_signal(status: &std::process::ExitStatus) -> Option<i32> {
353    use std::os::unix::process::ExitStatusExt;
354    status.signal()
355}
356
357#[cfg(not(unix))]
358fn exit_signal(_status: &std::process::ExitStatus) -> Option<i32> {
359    None
360}
361
362/// Append `chunk` to a rolling tail buffer, compacting to the last
363/// `cap` bytes whenever it grows past `2 * cap` (amortized O(1)). The
364/// exact final trim happens at EOF via [`trim_to_tail`]. Keeping the
365/// *tail* (not a prefix) matters because the CLI's terminal error line
366/// is at the end of stderr.
367fn append_capped_tail(buf: &mut Vec<u8>, chunk: &[u8], cap: usize) {
368    buf.extend_from_slice(chunk);
369    if buf.len() > cap.saturating_mul(2) {
370        trim_to_tail(buf, cap);
371    }
372}
373
374/// Drop all but the last `cap` bytes of `buf`.
375fn trim_to_tail(buf: &mut Vec<u8>, cap: usize) {
376    if buf.len() > cap {
377        let excess = buf.len() - cap;
378        buf.drain(0..excess);
379    }
380}
381
382/// Drain an arbitrary async reader of newline-delimited stream-json
383/// frames, forward every emitted `AgentEvent` on `tx`, and return
384/// an accumulator capturing the terminal `Cost` and `Done` values.
385///
386/// Pure async helper — no subprocess, no broker. Unit-tested with
387/// `tokio::io::duplex` in-memory pipes.
388pub(crate) async fn drain_async_reader<R: tokio::io::AsyncBufRead + Unpin>(
389    mut reader: R,
390    tx: &mpsc::UnboundedSender<AgentEvent>,
391) -> Result<AgentRunResult, String> {
392    let mut translator = ClaudeTranslator::new();
393    let mut accumulated = AgentRunResult::default();
394    let mut line = String::new();
395    loop {
396        line.clear();
397        let n = reader
398            .read_line(&mut line)
399            .await
400            .map_err(|e| format!("stdout read: {e}"))?;
401        if n == 0 {
402            break; // EOF
403        }
404        let trimmed = line.trim_end_matches(['\n', '\r']);
405        if !trimmed.starts_with('{') {
406            continue;
407        }
408        let Ok(frame) = serde_json::from_str::<serde_json::Value>(trimmed) else {
409            continue;
410        };
411        // Capture a terminal *error* result frame so the collector can
412        // fail the run even when claude reports the error on stdout and
413        // exits 0 (the translator still emits a hollow `Done`).
414        // codex P1 on #1353.
415        if frame.get("type").and_then(|v| v.as_str()) == Some("result")
416            && super::failure::is_error_result_frame(&frame)
417        {
418            accumulated.error_frame = Some(frame.clone());
419        }
420        for event in translator.translate(frame) {
421            // Capture terminal values before forwarding so a closed
422            // receiver doesn't lose the accumulated result.
423            match &event {
424                AgentEvent::Cost { cost_usd, tokens } => {
425                    accumulated.cost_usd = *cost_usd;
426                    accumulated.tokens = tokens.clone();
427                }
428                AgentEvent::Done {
429                    response,
430                    transcript,
431                } => {
432                    accumulated.response = response.clone();
433                    accumulated.transcript = transcript.clone();
434                }
435                _ => {}
436            }
437            // Forward — if the receiver is dropped, just stop
438            // sending (the drain still continues to capture the
439            // accumulated result).
440            let _ = tx.send(event);
441        }
442    }
443    Ok(accumulated)
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use serde_json::json;
450    use tokio::io::AsyncWriteExt;
451
452    /// Build a stream-json byte sequence simulating a complete
453    /// short claude run: streamed text + cost + result.
454    fn synthetic_stream(prompt_reply: &str, cost: f64) -> Vec<u8> {
455        let mut s = String::new();
456        for ch in prompt_reply.chars() {
457            s.push_str(&format!(
458                r#"{{"type":"stream_event","event":{{"type":"content_block_delta","delta":{{"type":"text_delta","text":"{ch}"}}}}}}"#,
459            ));
460            s.push('\n');
461        }
462        s.push_str(&format!(
463            r#"{{"type":"assistant","message":{{"content":[{{"type":"text","text":"{prompt_reply}"}}]}}}}
464"#
465        ));
466        s.push_str(&format!(
467            r#"{{"type":"result","cost_usd":{cost},"usage":{{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}},"result":"{prompt_reply}"}}
468"#
469        ));
470        s.into_bytes()
471    }
472
473    // ── Pillar 3 — commit-aware admission control (pure decision) ──────────────
474
475    #[test]
476    fn admit_spawn_allows_when_headroom_at_or_above_reserve() {
477        assert!(admit_spawn(Some(8.0), 2.0).is_ok());
478        // Exactly at the reserve admits (strict `<`).
479        assert!(admit_spawn(Some(2.0), 2.0).is_ok());
480    }
481
482    #[test]
483    fn admit_spawn_refuses_below_reserve() {
484        match admit_spawn(Some(1.0), 2.0) {
485            Err(AgentError::CommitPressure { avail_gb, reserve_gb }) => {
486                assert_eq!(avail_gb, 1.0);
487                assert_eq!(reserve_gb, 2.0);
488            }
489            other => panic!("expected CommitPressure, got {other:?}"),
490        }
491    }
492
493    #[test]
494    fn admit_spawn_admits_when_commit_unknown() {
495        // None (non-Windows / read failure) ⇒ no cheap limit to enforce ⇒ admit.
496        assert!(admit_spawn(None, 2.0).is_ok());
497    }
498
499    #[test]
500    fn agent_commit_reserve_defaults_when_env_absent_or_invalid() {
501        // Not asserting on the env var (tests share a process); just verify the
502        // default is the documented conservative floor and is non-negative.
503        assert_eq!(DEFAULT_AGENT_COMMIT_RESERVE_GB, 2.0);
504        assert!(DEFAULT_AGENT_COMMIT_RESERVE_GB >= 0.0);
505    }
506
507    #[tokio::test]
508    async fn drain_async_reader_accumulates_cost_and_done() {
509        let bytes = synthetic_stream("hello", 0.001);
510        let (mut w, r) = tokio::io::duplex(4096);
511        tokio::spawn(async move {
512            w.write_all(&bytes).await.unwrap();
513            w.shutdown().await.unwrap();
514        });
515
516        let (tx, mut rx) = mpsc::unbounded_channel();
517        let result = drain_async_reader(BufReader::new(r), &tx)
518            .await
519            .expect("drain ok");
520
521        assert_eq!(result.response, "hello");
522        assert_eq!(result.cost_usd, 0.001);
523        assert_eq!(result.tokens.input, 10);
524        assert_eq!(result.tokens.output, 5);
525        // Transcript contains the assistant turn.
526        assert_eq!(result.transcript.len(), 1);
527
528        // Events forwarded: 5 AssistantText (one per char) + Cost + Done.
529        drop(tx);
530        let mut evs = Vec::new();
531        while let Some(e) = rx.recv().await {
532            evs.push(e);
533        }
534        assert_eq!(evs.len(), 7, "got events: {evs:?}");
535        match &evs[evs.len() - 1] {
536            AgentEvent::Done { .. } => {}
537            other => panic!("expected last event Done, got {other:?}"),
538        }
539    }
540
541    #[tokio::test]
542    async fn drain_async_reader_skips_non_json_lines() {
543        // claude --verbose sometimes emits informational lines on
544        // stdout that aren't stream-json (rare but possible). Those
545        // must not break the drain.
546        let mut bytes: Vec<u8> = b"Reading config...\n".to_vec();
547        bytes.extend_from_slice(&synthetic_stream("ok", 0.0));
548        bytes.extend_from_slice(b"\n");
549
550        let (mut w, r) = tokio::io::duplex(4096);
551        tokio::spawn(async move {
552            w.write_all(&bytes).await.unwrap();
553            w.shutdown().await.unwrap();
554        });
555
556        let (tx, _rx) = mpsc::unbounded_channel();
557        let result = drain_async_reader(BufReader::new(r), &tx)
558            .await
559            .expect("drain ok");
560        assert_eq!(result.response, "ok");
561    }
562
563    #[tokio::test]
564    async fn drain_async_reader_returns_empty_on_no_stream() {
565        let (mut w, r) = tokio::io::duplex(4096);
566        tokio::spawn(async move {
567            // Just close — no output at all.
568            w.shutdown().await.unwrap();
569        });
570
571        let (tx, _rx) = mpsc::unbounded_channel();
572        let result = drain_async_reader(BufReader::new(r), &tx)
573            .await
574            .expect("drain ok");
575        // Default-empty result — the drain itself succeeds; the
576        // caller (drain_and_collect) is responsible for surfacing
577        // the "no Done event" as an error since it depends on
578        // exit status semantics.
579        assert!(result.response.is_empty());
580        assert_eq!(result.cost_usd, 0.0);
581    }
582
583    #[tokio::test]
584    async fn drain_async_reader_handles_multi_line_chunks() {
585        // BufReader's read_line is well-defined; this just guards
586        // against future regressions where someone might switch to a
587        // chunked reader.
588        let bytes = synthetic_stream("multi", 0.01);
589        let (mut w, r) = tokio::io::duplex(4096);
590        tokio::spawn(async move {
591            // Write in small chunks to exercise the read path.
592            for chunk in bytes.chunks(7) {
593                w.write_all(chunk).await.unwrap();
594            }
595            w.shutdown().await.unwrap();
596        });
597
598        let (tx, _rx) = mpsc::unbounded_channel();
599        let result = drain_async_reader(BufReader::new(r), &tx)
600            .await
601            .expect("drain ok");
602        assert_eq!(result.response, "multi");
603    }
604
605    #[tokio::test]
606    async fn drain_handles_malformed_json_gracefully() {
607        let mut bytes: Vec<u8> =
608            b"{this is not valid json\n{\"type\":\"unknown\"}\n".to_vec();
609        bytes.extend_from_slice(&synthetic_stream("recovered", 0.0));
610
611        let (mut w, r) = tokio::io::duplex(4096);
612        tokio::spawn(async move {
613            w.write_all(&bytes).await.unwrap();
614            w.shutdown().await.unwrap();
615        });
616
617        let (tx, _rx) = mpsc::unbounded_channel();
618        let result = drain_async_reader(BufReader::new(r), &tx)
619            .await
620            .expect("drain ok");
621        assert_eq!(result.response, "recovered");
622    }
623
624    #[tokio::test]
625    #[ignore = "requires `claude` CLI on PATH; run manually for end-to-end"]
626    async fn run_agent_end_to_end_with_real_claude() {
627        // Manual smoke: AGENTMUX_CLAUDE_BIN=/path/to/claude
628        // cargo test -p agentmux-srv -- --ignored
629        //     run_agent_end_to_end_with_real_claude
630        let (tx, mut rx) = mpsc::unbounded_channel();
631        let handle = run_agent(
632            AgentRef::default(),
633            AgentTask {
634                prompt: "What is 2+2? Respond with just the number.".to_string(),
635                context: serde_json::Map::new(),
636                max_turns: None,
637            },
638            tx,
639        )
640        .await
641        .expect("spawn ok");
642
643        // Drain events until done.
644        while let Some(_ev) = rx.recv().await {}
645
646        let result = handle
647            .final_result
648            .await
649            .expect("oneshot ok")
650            .expect("agent run ok");
651        assert!(result.response.contains('4'), "got: {}", result.response);
652        assert!(result.cost_usd > 0.0);
653    }
654
655    #[tokio::test]
656    async fn run_agent_with_bin_surfaces_spawn_failure() {
657        // Inject a known-nonexistent binary path so the spawn fails
658        // deterministically. Verifies the AgentError::Spawn path
659        // without touching env vars (set_var is unsound under
660        // concurrent test execution in Rust 1.81+).
661        let (tx, _rx) = mpsc::unbounded_channel();
662        let result = run_agent_with_bin(
663            "/definitely/does/not/exist/claude-xyz-test",
664            AgentRef::default(),
665            AgentTask {
666                prompt: "hi".to_string(),
667                context: serde_json::Map::new(),
668                max_turns: None,
669            },
670            tx,
671        )
672        .await;
673        match result {
674            Err(AgentError::Spawn(msg)) => {
675                assert!(
676                    msg.contains("spawn") || msg.contains("does/not/exist"),
677                    "spawn error message should reference the failure; got: {msg}"
678                );
679            }
680            Err(other) => panic!("expected Spawn error, got: {other}"),
681            Ok(_) => panic!("expected Spawn error, got Ok(handle)"),
682        }
683    }
684
685    /// End-to-end (Unix): a stub binary that writes a rate-limit line to
686    /// stderr and exits 1 must produce a *classified* failure naming the
687    /// cause and including the stderr tail — not a bare "exit 1".
688    /// Exercises G1 (stderr retained) + G2 (classify) of
689    /// SPEC_AGENT_FAILURE_DIAGNOSTICS.
690    #[cfg(unix)]
691    #[tokio::test]
692    async fn classified_failure_surfaces_cause_and_stderr() {
693
694        let path = std::env::temp_dir()
695            .join(format!("amux-stub-claude-{}.sh", uuid::Uuid::new_v4()));
696        write_stub_script(
697            &path,
698            &[
699                "echo 'API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited' >&2",
700                "exit 1",
701            ],
702        );
703
704        let (tx, _rx) = mpsc::unbounded_channel();
705        let handle = run_stub(
706            &path,
707            AgentTask {
708                prompt: "hi".to_string(),
709                context: serde_json::Map::new(),
710                max_turns: None,
711            },
712            tx,
713        )
714        .await
715        .expect("spawn ok");
716
717        let err = handle
718            .final_result
719            .await
720            .expect("oneshot ok")
721            .expect_err("run should fail");
722
723        assert!(
724            err.contains("Rate-limited"),
725            "explanation should name the class; got: {err}"
726        );
727        assert!(
728            err.to_lowercase().contains("rate limited"),
729            "stderr tail should be included; got: {err}"
730        );
731        assert!(
732            err.contains("retryable"),
733            "rate-limit is retryable; got: {err}"
734        );
735
736        let _ = std::fs::remove_file(&path);
737    }
738
739    /// End-to-end (Unix): stderr larger than the tail cap with the real
740    /// error on the LAST line must still classify correctly — the
741    /// rolling tail keeps the end, not a prefix. Regression test for
742    /// codex P2 on #1353.
743    #[cfg(unix)]
744    #[tokio::test]
745    async fn classified_failure_reads_error_past_stderr_cap() {
746
747        let path = std::env::temp_dir()
748            .join(format!("amux-stub-claude-big-{}.sh", uuid::Uuid::new_v4()));
749        write_stub_script(
750            &path,
751            &[
752                // ~70 KiB of filler (> 64 KiB tail cap), THEN the real error.
753                "head -c 70000 /dev/zero | tr '\\0' x >&2",
754                "printf '\\nAPI Error: Server is temporarily limiting requests (not your usage limit) Rate limited\\n' >&2",
755                "exit 1",
756            ],
757        );
758
759        let (tx, _rx) = mpsc::unbounded_channel();
760        let handle = run_stub(
761            &path,
762            AgentTask {
763                prompt: "hi".to_string(),
764                context: serde_json::Map::new(),
765                max_turns: None,
766            },
767            tx,
768        )
769        .await
770        .expect("spawn ok");
771
772        let err = handle
773            .final_result
774            .await
775            .expect("oneshot ok")
776            .expect_err("run should fail");
777
778        assert!(
779            err.contains("Rate-limited"),
780            "must classify from the tail past the cap; got: {}",
781            err.chars().take(160).collect::<String>()
782        );
783
784        let _ = std::fs::remove_file(&path);
785    }
786
787    #[test]
788    fn rolling_tail_keeps_last_bytes_past_cap() {
789        let cap = 8;
790        let mut buf = Vec::new();
791        for i in 0..100u8 {
792            append_capped_tail(&mut buf, &[i], cap);
793            assert!(buf.len() <= cap * 2, "rolling buffer must stay bounded");
794        }
795        trim_to_tail(&mut buf, cap);
796        assert_eq!(buf, vec![92, 93, 94, 95, 96, 97, 98, 99]);
797    }
798
799    #[test]
800    fn rolling_tail_single_large_chunk() {
801        let cap = 4;
802        let mut buf = Vec::new();
803        append_capped_tail(&mut buf, b"abcdefghij", cap);
804        trim_to_tail(&mut buf, cap);
805        assert_eq!(&buf, b"ghij");
806    }
807
808    #[tokio::test]
809    async fn drain_captures_error_result_frame() {
810        // An error result frame on stdout must be captured so the
811        // collector can fail the run even on exit 0. codex P1 #1353.
812        let bytes =
813            b"{\"type\":\"result\",\"is_error\":true,\"subtype\":\"error_during_execution\",\"error\":{\"message\":\"overloaded_error\"}}\n"
814                .to_vec();
815        let (mut w, r) = tokio::io::duplex(4096);
816        tokio::spawn(async move {
817            w.write_all(&bytes).await.unwrap();
818            w.shutdown().await.unwrap();
819        });
820        let (tx, _rx) = mpsc::unbounded_channel();
821        let result = drain_async_reader(BufReader::new(r), &tx)
822            .await
823            .expect("drain ok");
824        assert!(
825            result.error_frame.is_some(),
826            "error result frame should be captured"
827        );
828    }
829
830    /// Write an executable `/bin/sh` stub at `path`, atomically.
831    ///
832    /// Built at a sibling temp name and renamed into place, rather than created
833    /// directly at `path`. Creating it at its final path races every other test
834    /// that spawns a subprocess: between `File::create` and the handle being
835    /// dropped, another thread's `fork()` inherits the still-writable fd, and
836    /// the child holds it open until it execs — so exec'ing that same path here
837    /// fails with `ETXTBSY` ("Text file busy"). `CLOEXEC`, which Rust sets by
838    /// default, does not help: it closes on *exec*, and the window is between
839    /// fork and exec.
840    ///
841    /// Observed ~1 run in 3 of the parallel suite (`spawn ok: Spawn("spawn
842    /// /tmp/amux-stub-claude-okerr-….sh: Text file busy (os error 26)")`). CI
843    /// runs `--test-threads=1`, which is why it never fired there and sat
844    /// unexplained locally.
845    ///
846    /// `rename(2)` is atomic and the final path is never open for writing, so
847    /// no forked child can hold a write fd to it.
848    #[cfg(unix)]
849    fn write_stub_script(path: &std::path::Path, lines: &[&str]) {
850        use std::io::Write as _;
851        use std::os::unix::fs::PermissionsExt as _;
852        let tmp = path.with_extension("sh.partial");
853        {
854            let mut f = std::fs::File::create(&tmp).unwrap();
855            writeln!(f, "#!/bin/sh").unwrap();
856            for l in lines {
857                writeln!(f, "{l}").unwrap();
858            }
859            f.flush().unwrap();
860        }
861        let mut perms = std::fs::metadata(&tmp).unwrap().permissions();
862        perms.set_mode(0o755);
863        std::fs::set_permissions(&tmp, perms).unwrap();
864        std::fs::rename(&tmp, path).unwrap();
865    }
866
867    /// [`run_agent_with_bin`] on a stub from [`write_stub_script`], retrying
868    /// while the stub is "Text file busy". ETXTBSY is about the file, not its
869    /// path: a child forked by another test while the stub was open for
870    /// writing holds that write fd until it execs, and the rename above keeps
871    /// the same file — so the race it was meant to close stays open under the
872    /// parallel suite. It is always transient.
873    #[cfg(unix)]
874    async fn run_stub(
875        path: &std::path::Path,
876        task: AgentTask,
877        tx: mpsc::UnboundedSender<AgentEvent>,
878    ) -> Result<AgentRunHandle, AgentError> {
879        let mut attempt = 0;
880        loop {
881            match run_agent_with_bin(path.to_str().unwrap(), AgentRef::default(), task.clone(), tx.clone()).await {
882                Err(e) if attempt < 20 && format!("{e:?}").contains("Text file busy") => {
883                    attempt += 1;
884                    tokio::time::sleep(std::time::Duration::from_millis(25)).await;
885                }
886                r => return r,
887            }
888        }
889    }
890
891    /// End-to-end (Unix): claude can report an error on stdout and still
892    /// exit 0; the runner must NOT treat that as success. codex P1 #1353.
893    #[cfg(unix)]
894    #[tokio::test]
895    async fn stdout_error_result_with_exit_zero_is_a_failure() {
896        let path = std::env::temp_dir()
897            .join(format!("amux-stub-claude-okerr-{}.sh", uuid::Uuid::new_v4()));
898        // Emit an error result frame on stdout, then exit 0. The JSON lives in
899        // a `let` so its braces are data, not format placeholders.
900        let frame = r#"{"type":"result","is_error":true,"subtype":"error_during_execution","error":{"message":"overloaded_error: upstream busy"}}"#;
901        write_stub_script(&path, &[&format!("echo '{frame}'"), "exit 0"]);
902
903        let (tx, _rx) = mpsc::unbounded_channel();
904        let handle = run_stub(
905            &path,
906            AgentTask {
907                prompt: "hi".to_string(),
908                context: serde_json::Map::new(),
909                max_turns: None,
910            },
911            tx,
912        )
913        .await
914        .expect("spawn ok");
915
916        let err = handle
917            .final_result
918            .await
919            .expect("oneshot ok")
920            .expect_err("stdout-reported error with exit 0 must fail");
921        assert!(
922            err.to_lowercase().contains("overloaded"),
923            "should classify the stdout error; got: {err}"
924        );
925
926        let _ = std::fs::remove_file(&path);
927    }
928
929    #[test]
930    fn agent_task_max_turns_field_round_trips() {
931        // Reagent P1 + codex P2 on PR #834: the max_turns field is
932        // forwarded to the subprocess via `--max-turns N`. We can't
933        // assert the actual CLI argument here without spawning, but
934        // we can verify the field flows through AgentTask's
935        // serde + Clone path without loss — the subprocess wiring
936        // is exercised by the end-to-end test.
937        let task = AgentTask {
938            prompt: "x".into(),
939            context: serde_json::Map::new(),
940            max_turns: Some(7),
941        };
942        let v = serde_json::to_value(&task).unwrap();
943        assert_eq!(v["maxTurns"], json!(7));
944        let back: AgentTask = serde_json::from_value(v).unwrap();
945        assert_eq!(back.max_turns, Some(7));
946    }
947
948    #[test]
949    fn agent_run_result_has_sensible_defaults() {
950        let r = AgentRunResult::default();
951        assert_eq!(r.response, "");
952        assert_eq!(r.cost_usd, 0.0);
953        assert!(r.transcript.is_empty());
954        let _ = json!(r); // serializes without panic
955    }
956}