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}