Skip to main content

magi/
agent.rs

1//! Driving agent CLIs.
2//!
3//! Every agent in magi is a subscription CLI (`claude`, `opencode`, `agy`) or
4//! an arbitrary command, invoked headless in a working directory. There is no
5//! API-key path on purpose: the CLIs carry the operator's own plan, and they
6//! are the only interface that exposes an agent's whole tool loop rather than a
7//! single completion.
8//!
9//! # Seats, not agents
10//!
11//! Conversations are keyed by *seat* ([`SeatState::key`]), never by agent id. A
12//! model that implements candidate B and also sits as judge 3 gets two
13//! unrelated conversations, so the judge cannot recognise its own work from
14//! having written it. Sessions are what make deliberation affordable — a judge
15//! remembers its own argument instead of being re-fed the entire candidate set
16//! — and seat scoping is what keeps that from destroying blindness.
17//!
18//! # Session mechanics per CLI
19//!
20//! | CLI | open | resume |
21//! |-----|------|--------|
22//! | `claude` | `--session-id <uuid>` (magi mints it) | `--resume <uuid>` |
23//! | `opencode` | `--format json` reports `sessionID` | `-s <id>` |
24//! | `agy` | `--output-format json` reports `conversation_id` | `--conversation <id>` |
25//!
26//! Claude is the only one magi can address before the first turn; the other two
27//! report an id back, so [`SeatState::captured_session`] stays `None` until a
28//! turn has completed and [`has_session`] answers honestly instead of
29//! optimistically.
30use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32use std::process::Stdio;
33use std::time::{Duration, Instant};
34
35use anyhow::{Context as _, Result, bail};
36use serde::{Deserialize, Serialize};
37use std::sync::{Arc, Mutex};
38
39use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
40use tokio::process::Command;
41
42use crate::config::{AgentKind, AgentSpec, Delivery};
43use crate::proc::Quiet as _;
44use crate::rng::SplitMix64;
45
46/// Conversation state for one seat, persisted with the run so `magi run
47/// --resume` continues the same CLI conversations.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SeatState {
50    /// Stable seat name, e.g. `impl-A`, `judge-2`, `review-1`, `fix`.
51    pub key: String,
52    /// Agent id occupying the seat.
53    pub agent: String,
54    /// Turns already taken in this seat.
55    pub turns: usize,
56    /// Claude session uuid, minted up front so the first turn and every resume
57    /// agree on it without parsing anything back.
58    pub claude_session: Option<String>,
59    /// Session id reported by a CLI that mints its own (`opencode`, `agy`).
60    pub captured_session: Option<String>,
61}
62
63impl SeatState {
64    /// New seat. `run_seed` scopes the minted Claude uuid to this run.
65    pub fn new(key: &str, agent: &str, run_seed: u64) -> Self {
66        let mut rng = SplitMix64::new(run_seed ^ crate::rng::fnv1a(key));
67        Self {
68            key: key.to_owned(),
69            agent: agent.to_owned(),
70            turns: 0,
71            claude_session: Some(rng.uuid_v4()),
72            captured_session: None,
73        }
74    }
75}
76
77/// Can a follow-up prompt rely on this seat remembering the conversation?
78pub fn has_session(kind: AgentKind, seat: &SeatState, sessions_enabled: bool) -> bool {
79    if !sessions_enabled || seat.turns == 0 {
80        return false;
81    }
82    match kind {
83        AgentKind::Claude => seat.claude_session.is_some(),
84        AgentKind::Opencode | AgentKind::Antigravity => seat.captured_session.is_some(),
85        AgentKind::Command => true,
86    }
87}
88
89/// One agent invocation.
90#[derive(Debug)]
91pub struct Invocation<'a> {
92    /// Working directory. Always a real checkout, so every CLI can read the
93    /// repository without per-vendor "extra directory" flags.
94    pub cwd: &'a Path,
95    /// The full prompt.
96    pub prompt: &'a str,
97    /// Wall-clock limit; the process tree is killed when it elapses.
98    pub timeout: Duration,
99    /// May the agent modify files? Judges and reviewers may not.
100    pub allow_write: bool,
101    /// Continue this seat's conversation when the CLI supports it.
102    pub sessions: bool,
103    /// Directory for prompt / stdout / stderr artifacts.
104    pub artifacts: &'a Path,
105    /// Artifact filename stem.
106    pub stem: &'a str,
107    /// Run this invocation belongs to. Exported as `MAGI_RUN` so an agent that
108    /// files a task with `magi task add` is attributed to the run that was
109    /// paying for it, rather than looking like a human wandered by.
110    pub run: &'a str,
111    /// Graph node being executed, e.g. `implement` or `review`. Exported as
112    /// `MAGI_NODE` for the same reason: "who asked for this" is the first
113    /// question about an autonomously created task.
114    pub node: &'a str,
115}
116
117/// Evidence that a CLI ran out of its rate limit / quota, distinct from an
118/// ordinary failure.
119///
120/// `reset` is free text: CLIs render the reset time in their own locale, and
121/// parsing it exactly would be a bug factory. When it is not readable we say
122/// nothing rather than invent a format.
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124pub struct Quota {
125    /// Human-readable reset time, when the CLI printed one.
126    #[serde(default)]
127    pub reset: Option<String>,
128}
129
130/// A CLI hung up on its own stream while the agent was working.
131///
132/// Separate from a failure because the work was done and billed, and separate
133/// from a [`Quota`] because it is worth asking again: the answer is in the
134/// conversation, not lost to a limit that has to reset first. See
135/// [`dropped_stream`].
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137pub struct Dropped {
138    /// What the CLI said as it hung up, verbatim.
139    pub why: String,
140    /// Output tokens the CLI reported before it did - the evidence that this
141    /// was a delivery failure and not an agent that produced nothing.
142    pub output_tokens: u64,
143}
144
145/// Result of an agent invocation.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct AgentOutput {
148    /// The agent's final message, extracted from whatever the CLI printed.
149    pub text: String,
150    /// Exit status code.
151    pub exit_code: Option<i32>,
152    /// Did the invocation hit its timeout?
153    pub timed_out: bool,
154    /// Wall-clock duration.
155    pub duration_ms: u64,
156    /// Artifact file names, relative to the run's `artifacts/` directory.
157    pub artifacts: Vec<String>,
158    /// Rate-limit / quota exhaustion, when it can be told apart from a normal
159    /// failure. `None` for a normal failure, a timeout, or a CLI we cannot
160    /// read — the conservative default.
161    #[serde(default)]
162    pub quota: Option<Quota>,
163    /// The CLI hung up on its own stream after the agent had done billed
164    /// work. `None` unless that exact shape was recognised — see
165    /// [`dropped_stream`].
166    #[serde(default)]
167    pub dropped: Option<Dropped>,
168}
169
170impl AgentOutput {
171    /// Did the CLI exit cleanly with something to say?
172    pub fn usable(&self) -> bool {
173        !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
174    }
175
176    /// Did this invocation run out of the CLI's rate limit / quota?
177    pub fn quota_exhausted(&self) -> bool {
178        self.quota.is_some()
179    }
180
181    /// Did the agent work and the CLI fail to deliver it?
182    ///
183    /// Worth re-asking, unlike [`AgentOutput::quota_exhausted`]: the answer is
184    /// in a conversation this process can resume.
185    pub fn work_undelivered(&self) -> bool {
186        self.dropped.is_some()
187    }
188}
189
190/// How long to keep reading a pipe after the child is gone.
191///
192/// Bounded on purpose: a surviving grandchild can hold the write end open
193/// forever, and the graph must not hang on a process it has already killed.
194const PIPE_GRACE: Duration = Duration::from_secs(3);
195
196/// Bytes a pipe reader has accumulated so far, shared with whoever spawned it.
197type Captured = Arc<Mutex<Vec<u8>>>;
198
199/// Read `pipe` to end in its own task, appending into a buffer the caller can
200/// inspect at any time.
201///
202/// The buffer is shared rather than returned because the interesting moment is
203/// exactly the one where the reader has *not* finished: a killed agent's pipe
204/// may still be held open by a surviving grandchild, and the bytes that did
205/// arrive are the only evidence of what it was doing. An earlier version
206/// returned the buffer from the task and dropped it on timeout, which is how
207/// `<stem>.out` came to be empty on every timeout.
208fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
209where
210    R: tokio::io::AsyncRead + Unpin + Send + 'static,
211{
212    let buf: Captured = Arc::new(Mutex::new(Vec::new()));
213    let Some(mut pipe) = pipe else {
214        return (buf, None);
215    };
216    let sink = Arc::clone(&buf);
217    let handle = tokio::spawn(async move {
218        let mut chunk = [0u8; 8192];
219        loop {
220            match pipe.read(&mut chunk).await {
221                Ok(0) | Err(_) => break,
222                Ok(n) => {
223                    if let Ok(mut guard) = sink.lock() {
224                        guard.extend_from_slice(&chunk[..n]);
225                    }
226                }
227            }
228        }
229    });
230    (buf, Some(handle))
231}
232
233/// Take whatever a reader has captured, giving it at most `grace` to finish.
234///
235/// A reader still blocked after that is abandoned, not awaited — but its bytes
236/// come back either way, which is the whole point.
237async fn collect(
238    buf: &Captured,
239    handle: Option<tokio::task::JoinHandle<()>>,
240    grace: Duration,
241) -> String {
242    if let Some(handle) = handle {
243        if tokio::time::timeout(grace, handle).await.is_err() {
244            tracing::debug!("a pipe is still held open after the child exited");
245        }
246    }
247    let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
248    String::from_utf8_lossy(&bytes).into_owned()
249}
250
251/// Invoke `spec` for `seat`, updating the seat's conversation state.
252pub async fn invoke(
253    spec: &AgentSpec,
254    seat: &mut SeatState,
255    inv: &Invocation<'_>,
256) -> Result<AgentOutput> {
257    tokio::fs::create_dir_all(inv.artifacts)
258        .await
259        .with_context(|| format!("create {}", inv.artifacts.display()))?;
260    let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
261    tokio::fs::write(&prompt_path, inv.prompt)
262        .await
263        .with_context(|| format!("write {}", prompt_path.display()))?;
264
265    let plan = build_command(spec, seat, inv, &prompt_path)?;
266    tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
267
268    let started = Instant::now();
269    let mut cmd = Command::new(&plan.argv[0]);
270    cmd.args(&plan.argv[1..])
271        .current_dir(inv.cwd)
272        .envs(&spec.env)
273        .env("MAGI_SEAT", &seat.key)
274        .env("MAGI_TURN", seat.turns.to_string())
275        .env("MAGI_RUN", inv.run)
276        .env("MAGI_NODE", inv.node)
277        .env("MAGI_PROMPT_FILE", &prompt_path)
278        .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
279        .env("GIT_TERMINAL_PROMPT", "0")
280        .stdin(if plan.stdin.is_some() {
281            Stdio::piped()
282        } else {
283            Stdio::null()
284        })
285        .stdout(Stdio::piped())
286        .stderr(Stdio::piped())
287        .kill_on_drop(true)
288        // No console window. `magi web` has no console of its own, so Windows
289        // would give each agent a fresh one - and draw it. See `crate::proc`.
290        .quiet();
291
292    let mut child = cmd
293        .spawn()
294        .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
295    // Feed stdin from a task rather than inline: a `command` agent that never
296    // reads its stdin, or a prompt larger than the pipe buffer, would
297    // otherwise deadlock here before the process is ever waited on.
298    if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), child.stdin.take()) {
299        tokio::spawn(async move {
300            sink.write_all(body.as_bytes()).await.ok();
301            sink.shutdown().await.ok();
302        });
303    }
304
305    // Drain the pipes in their own tasks, and wait on the *process*, not on
306    // end-of-file. Two failures come out of conflating those:
307    //
308    // 1. `wait_with_output` returns when both pipes reach EOF, which is not
309    //    when the child exits. A CLI that leaves a helper process holding the
310    //    inherited stdout handle - normal on Windows, where a `.cmd` shim and
311    //    its grandchildren share handles - never closes the pipe, so a seat
312    //    that answered in five minutes was billed the full hour and then
313    //    recorded as a timeout. The answer was thrown away with it.
314    // 2. Cancelling `wait_with_output` at the timeout drops the buffers it
315    //    owned, so `<stem>.out` and `<stem>.err` were written empty exactly
316    //    when an operator needs them most. "It printed nothing" and "we
317    //    discarded what it printed" looked identical on disk.
318    //
319    // Now the readers own the bytes, so a timeout keeps whatever arrived, and
320    // the wait ends at exit even if a stray handle stays open.
321    let (out_buf, out_reader) = drain(child.stdout.take());
322    let (err_buf, err_reader) = drain(child.stderr.take());
323
324    let (code, timed_out) = match tokio::time::timeout(inv.timeout, child.wait()).await {
325        Ok(res) => {
326            let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
327            (status.code(), false)
328        }
329        Err(_) => {
330            tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
331            // Kill the tree so the readers see EOF instead of hanging with it.
332            child.start_kill().ok();
333            (None, true)
334        }
335    };
336
337    // The child is gone either way, so the readers are bounded now. A grace
338    // window rather than an unbounded await: a surviving grandchild can still
339    // hold the write end open, and losing a few trailing bytes beats hanging
340    // the graph on a process we no longer control.
341    let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
342    let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
343
344    let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
345    let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
346    tokio::fs::write(&out_path, &stdout).await.ok();
347    tokio::fs::write(&err_path, &stderr).await.ok();
348
349    let extracted = extract(spec.kind, &stdout);
350    if let Some(session) = extracted.session {
351        match spec.kind {
352            AgentKind::Claude => seat.claude_session = Some(session),
353            AgentKind::Opencode | AgentKind::Antigravity => seat.captured_session = Some(session),
354            AgentKind::Command => {}
355        }
356    }
357    if let Some(status) = &extracted.status
358        && !status.eq_ignore_ascii_case("success")
359    {
360        tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
361    }
362    let text = if extracted.text.trim().is_empty() {
363        // A CLI that printed only to stderr still told us something.
364        if stdout.trim().is_empty() {
365            stderr.trim().to_owned()
366        } else {
367            stdout.trim().to_owned()
368        }
369    } else {
370        extracted.text
371    };
372    seat.turns += 1;
373
374    Ok(AgentOutput {
375        text,
376        exit_code: code,
377        timed_out,
378        duration_ms: started.elapsed().as_millis() as u64,
379        artifacts: vec![
380            file_name(&prompt_path),
381            file_name(&out_path),
382            file_name(&err_path),
383        ],
384        quota: extracted.quota,
385        dropped: extracted.dropped,
386    })
387}
388
389fn file_name(p: &Path) -> String {
390    p.file_name()
391        .unwrap_or_default()
392        .to_string_lossy()
393        .into_owned()
394}
395
396/// The argv plus optional stdin body for one invocation.
397#[derive(Debug)]
398struct Plan {
399    argv: Vec<String>,
400    stdin: Option<String>,
401}
402
403/// How a file-delivered prompt is pointed at, per CLI.
404///
405/// `agy` has a native file-context syntax, `@<path>`, and it is measurably the
406/// better contract: on the same trivial task it finished in 17s against 73s for
407/// the prose form, because prose makes the model spend a tool round-trip
408/// deciding to read the file. It is also the form yukimemi/rvpm proved out.
409///
410/// opencode has no equivalent, so it gets the prose. That is not a fallback
411/// worth apologising for — it works, and it is what the winning opencode
412/// candidates on this repository have been driven by all along.
413fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
414    if matches!(kind, AgentKind::Antigravity) {
415        return format!("@{}", prompt_path.display());
416    }
417    format!(
418        "Read the file at {} and follow every instruction in it exactly. That \
419         file is your complete task description; this message contains nothing \
420         else.",
421        prompt_path.display()
422    )
423}
424
425fn build_command(
426    spec: &AgentSpec,
427    seat: &SeatState,
428    inv: &Invocation<'_>,
429    prompt_path: &Path,
430) -> Result<Plan> {
431    let mut argv: Vec<String> = Vec::new();
432    let mut stdin: Option<String> = None;
433    let delivery = spec.delivery();
434    let resuming = has_session(spec.kind, seat, inv.sessions);
435
436    match spec.kind {
437        AgentKind::Claude => {
438            argv.push("claude".to_owned());
439            argv.push("-p".to_owned());
440            argv.push("--output-format".to_owned());
441            argv.push("json".to_owned());
442            if let Some(m) = &spec.model {
443                argv.push("--model".to_owned());
444                argv.push(m.clone());
445            }
446            if inv.sessions {
447                let uuid = seat
448                    .claude_session
449                    .as_deref()
450                    .context("claude seat is missing its session uuid")?;
451                argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
452                argv.push(uuid.to_owned());
453            }
454            argv.push("--permission-mode".to_owned());
455            argv.push("bypassPermissions".to_owned());
456            if !inv.allow_write {
457                argv.push("--disallowed-tools".to_owned());
458                argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
459            }
460        }
461        AgentKind::Opencode => {
462            argv.push("opencode".to_owned());
463            argv.push("run".to_owned());
464            argv.push("--format".to_owned());
465            argv.push("json".to_owned());
466            argv.push("--dir".to_owned());
467            argv.push(inv.cwd.to_string_lossy().into_owned());
468            // `--auto` gates *every* permission, reads included: without it a
469            // non-interactive opencode cannot even open the prompt file, and
470            // the seat drops out of the panel with "the user rejected
471            // permission to use this specific tool call". opencode has no
472            // read-only mode, so read-only seats rely on the prompt plus the
473            // fact that judge and reviewer worktrees are disposable — judges'
474            // are deleted after the tally, reviewers' are reset to the commit
475            // under review every round.
476            argv.push("--auto".to_owned());
477            if let Some(m) = &spec.model {
478                argv.push("-m".to_owned());
479                argv.push(m.clone());
480            }
481            if resuming {
482                argv.push("-s".to_owned());
483                argv.push(
484                    seat.captured_session
485                        .clone()
486                        .expect("has_session checked the id is present"),
487                );
488            }
489        }
490        AgentKind::Antigravity => {
491            argv.push("agy".to_owned());
492            argv.push("--output-format".to_owned());
493            argv.push("json".to_owned());
494            // agy's print mode gives up after 5 minutes by default, which is
495            // far below an implementation node's budget.
496            argv.push("--print-timeout".to_owned());
497            argv.push(format!("{}s", inv.timeout.as_secs()));
498            argv.push("--mode".to_owned());
499            argv.push(
500                if inv.allow_write {
501                    "accept-edits"
502                } else {
503                    "plan"
504                }
505                .to_owned(),
506            );
507            if inv.allow_write {
508                argv.push("--dangerously-skip-permissions".to_owned());
509            }
510            if let Some(m) = &spec.model {
511                argv.push("--model".to_owned());
512                argv.push(m.clone());
513            }
514            if resuming {
515                argv.push("--conversation".to_owned());
516                argv.push(
517                    seat.captured_session
518                        .clone()
519                        .expect("has_session checked the id is present"),
520                );
521            }
522            // The prompt file lives outside the worktree, so the workspace has
523            // to be widened to reach it.
524            if delivery == Delivery::File {
525                argv.push("--add-dir".to_owned());
526                argv.push(inv.artifacts.to_string_lossy().into_owned());
527            }
528        }
529        AgentKind::Command => {
530            if spec.command.is_empty() {
531                bail!("agent `{}` has kind = \"command\" but no command", spec.id);
532            }
533            let vars: BTreeMap<&str, String> = BTreeMap::from([
534                ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
535                ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
536                ("{label}", seat.key.clone()),
537                ("{session}", seat.claude_session.clone().unwrap_or_default()),
538            ]);
539            for raw in &spec.command {
540                let mut arg = raw.clone();
541                for (k, v) in &vars {
542                    if arg.contains(k) {
543                        arg = arg.replace(k, v);
544                    }
545                }
546                argv.push(arg);
547            }
548        }
549    }
550
551    argv.extend(spec.extra_args.iter().cloned());
552
553    // `agy` takes the prompt as the value of `-p`, so the flag has to be
554    // emitted right before whatever the delivery mode produces.
555    if spec.kind == AgentKind::Antigravity {
556        argv.push("-p".to_owned());
557    }
558    match delivery {
559        Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
560            // agy has no text stdin path; fall back to the pointer file.
561            argv.push(pointer(spec.kind, prompt_path));
562        }
563        Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
564        Delivery::Argv => argv.push(inv.prompt.to_owned()),
565        Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
566    }
567
568    Ok(Plan { argv, stdin })
569}
570
571/// What a CLI's stdout yielded.
572#[derive(Debug, Default)]
573struct Extracted {
574    text: String,
575    session: Option<String>,
576    status: Option<String>,
577    quota: Option<Quota>,
578    dropped: Option<Dropped>,
579}
580
581/// Pull the agent's message (and any session id) out of a CLI's stdout.
582fn extract(kind: AgentKind, stdout: &str) -> Extracted {
583    match kind {
584        AgentKind::Claude => {
585            let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
586                return Extracted {
587                    text: stdout.trim().to_owned(),
588                    ..Extracted::default()
589                };
590            };
591            Extracted {
592                text: v
593                    .get("result")
594                    .and_then(|r| r.as_str())
595                    .unwrap_or_default()
596                    .to_owned(),
597                session: v
598                    .get("session_id")
599                    .and_then(|s| s.as_str())
600                    .map(str::to_owned),
601                status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
602                    if e {
603                        "error".to_owned()
604                    } else {
605                        "success".to_owned()
606                    }
607                }),
608                quota: claude_quota(&v),
609                // Claude reports a truncated stream as an ordinary error; the
610                // shape `dropped_stream` keys on is agy's.
611                dropped: None,
612            }
613        }
614        AgentKind::Opencode => {
615            // A JSONL event stream: text parts concatenated in arrival order.
616            let mut text = String::new();
617            let mut session = None;
618            for line in stdout.lines() {
619                let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
620                    continue;
621                };
622                if session.is_none() {
623                    session = v
624                        .get("sessionID")
625                        .and_then(|s| s.as_str())
626                        .map(str::to_owned);
627                }
628                let part = v.get("part").unwrap_or(&serde_json::Value::Null);
629                if part.get("type").and_then(|t| t.as_str()) == Some("text")
630                    && let Some(t) = part.get("text").and_then(|t| t.as_str())
631                {
632                    if !text.is_empty() {
633                        text.push('\n');
634                    }
635                    text.push_str(t);
636                }
637            }
638            Extracted {
639                text,
640                session,
641                status: None,
642                quota: None,
643                dropped: None,
644            }
645        }
646        AgentKind::Antigravity => {
647            // agy prints warnings before the JSON object, so parse the last
648            // line that is one rather than the whole stream.
649            let obj = stdout
650                .lines()
651                .rev()
652                .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
653            let Some(v) = obj else {
654                return Extracted {
655                    text: stdout.trim().to_owned(),
656                    ..Extracted::default()
657                };
658            };
659            Extracted {
660                text: v
661                    .get("response")
662                    .and_then(|r| r.as_str())
663                    .unwrap_or_default()
664                    .trim()
665                    .to_owned(),
666                session: v
667                    .get("conversation_id")
668                    .and_then(|s| s.as_str())
669                    .map(str::to_owned),
670                status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
671                quota: None,
672                dropped: dropped_stream(&v),
673            }
674        }
675        AgentKind::Command => {
676            // A `command` agent may wrap a subscription CLI (a fixture, or a
677            // thin shim around `claude`). If its output is the claude error
678            // shape we recognise the quota the same way, so tests and wrappers
679            // do not need their own detection; anything else is just text.
680            let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
681            let quota = parsed.as_ref().and_then(claude_quota);
682            // A `command` fixture may also stand in for a CLI that hangs up on
683            // its own stream, which is how that path is tested.
684            let dropped = parsed.as_ref().and_then(dropped_stream);
685            Extracted {
686                text: stdout.trim().to_owned(),
687                session: None,
688                status: None,
689                quota,
690                dropped,
691            }
692        }
693    }
694}
695
696/// Recognise claude's rate-limit error shape, when it is present.
697///
698/// The only output we have observed is the JSON object carrying `is_error:
699/// true` and a `result` mentioning the session limit. We key on exactly that;
700/// every other CLI (and any future shape) returns `None` and is treated as an
701/// ordinary failure — the conservative side.
702fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
703    let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
704    if !is_err {
705        return None;
706    }
707    let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
708    if !result.to_lowercase().contains("session limit") {
709        return None;
710    }
711    // "…session limit · resets 4:50am (Asia/Tokyo)". The timezone read is not
712    // worth parsing exactly; keep the whole phrase after "resets" as free text.
713    let reset = result
714        .split("resets ")
715        .nth(1)
716        .map(str::trim)
717        .filter(|s| !s.is_empty())
718        .map(str::to_owned);
719    Some(Quota { reset })
720}
721
722/// Recognise a CLI that gave up on its own stream while the agent was working.
723///
724/// Observed once, verbatim, from `agy` on a candidate that produced nothing:
725///
726/// ```text
727/// {"conversation_id":"36743d06-…","status":"ERROR","response":"",
728///  "error":"the connection to the agent was interrupted before the response
729///           finished: subscriber fell behind updates, stalled for 5s",
730///  "duration_seconds":431.19,"num_turns":1,
731///  "usage":{"input_tokens":260113,"output_tokens":14267,
732///           "thinking_tokens":9695,"cache_read_tokens":2200925}}
733/// ```
734///
735/// Seven minutes of work and fourteen thousand output tokens, billed, with an
736/// empty `response`: the agent did the job and the CLI's own subscriber fell
737/// behind and hung up. That is **not** an agent that failed to implement, and
738/// counting it as one is how `agy` came to read as 0 wins in 4 entries with
739/// five empty candidates - a number that has twice been used to argue the seat
740/// out of the roster, and twice been wrong (see `cb6b830`, which reverted the
741/// first removal: *"agy does not fail to implement, it fails to report"*).
742///
743/// The distinction that matters is **billed work with nothing delivered**, so
744/// that is what this keys on: an error status, an empty response, and a usage
745/// report showing output tokens. Everything else - including an error with no
746/// usage at all - returns `None` and stays an ordinary failure, the
747/// conservative side, exactly as [`claude_quota`] treats shapes it does not
748/// recognise.
749///
750/// Unlike a quota, this **is** worth re-asking: the work exists in the
751/// conversation the CLI just abandoned, and `conversation_id` is right there.
752fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
753    let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
754    if !status.eq_ignore_ascii_case("error") {
755        return None;
756    }
757    let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
758    if !response.trim().is_empty() {
759        // It answered. Whatever the status says, there is something to read.
760        return None;
761    }
762    let produced = v
763        .get("usage")
764        .and_then(|u| u.get("output_tokens"))
765        .and_then(serde_json::Value::as_u64)
766        .unwrap_or(0);
767    if produced == 0 {
768        // An error with nothing produced is just an error.
769        return None;
770    }
771    Some(Dropped {
772        why: v
773            .get("error")
774            .and_then(|e| e.as_str())
775            .unwrap_or("the CLI ended the stream without delivering its answer")
776            .trim()
777            .to_owned(),
778        output_tokens: produced,
779    })
780}
781
782/// Preflight: which configured agents are not runnable here?
783pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
784    let mut missing = Vec::new();
785    for s in specs {
786        let program = match s.kind {
787            AgentKind::Command => s.command.first().map(String::as_str),
788            other => other.program(),
789        };
790        if let Some(p) = program
791            && !crate::config::which(p)
792            && !Path::new(p).is_file()
793            && !missing.iter().any(|m: &String| m == p)
794        {
795            missing.push(p.to_owned());
796        }
797    }
798    missing
799}
800
801/// Absolute path of a run's artifact directory.
802pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
803    run_dir.join("artifacts")
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809
810    fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
811        AgentSpec {
812            id: "a".to_owned(),
813            kind,
814            model: model.map(str::to_owned),
815            command: vec!["echo".to_owned(), "{label}".to_owned()],
816            extra_args: Vec::new(),
817            env: BTreeMap::new(),
818            prompt_delivery: None,
819        }
820    }
821
822    fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
823        Invocation {
824            cwd,
825            prompt: "do the thing",
826            timeout: Duration::from_secs(900),
827            allow_write,
828            sessions: true,
829            artifacts: art,
830            stem: "t",
831            run: "test-run",
832            node: "test",
833        }
834    }
835
836    fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
837        build_command(
838            &spec(kind, None),
839            seat,
840            &inv(Path::new("."), Path::new("/art"), allow_write),
841            Path::new("/art/p.md"),
842        )
843        .unwrap()
844    }
845
846    #[test]
847    fn claude_mints_then_resumes_the_same_uuid() {
848        let mut seat = SeatState::new("judge-1", "a", 7);
849        let uuid = seat.claude_session.clone().unwrap();
850        let first = plan_for(AgentKind::Claude, &seat, true);
851        assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
852        assert!(!first.argv.iter().any(|a| a == "--resume"));
853
854        seat.turns = 1;
855        let second = plan_for(AgentKind::Claude, &seat, true);
856        assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
857        assert!(!second.argv.iter().any(|a| a == "--session-id"));
858    }
859
860    #[test]
861    fn read_only_seats_cannot_edit() {
862        let seat = SeatState::new("judge-1", "a", 7);
863        let claude = plan_for(AgentKind::Claude, &seat, false);
864        assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
865        assert!(
866            !plan_for(AgentKind::Claude, &seat, true)
867                .argv
868                .iter()
869                .any(|a| a == "--disallowed-tools")
870        );
871
872        let agy = plan_for(AgentKind::Antigravity, &seat, false);
873        assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
874        assert!(
875            !agy.argv
876                .iter()
877                .any(|a| a == "--dangerously-skip-permissions")
878        );
879        let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
880        assert!(
881            agy_rw
882                .argv
883                .windows(2)
884                .any(|w| w == ["--mode", "accept-edits"])
885        );
886        assert!(
887            agy_rw
888                .argv
889                .iter()
890                .any(|a| a == "--dangerously-skip-permissions")
891        );
892        // agy is pointed at its prompt with its own `@<path>` syntax, not with
893        // prose asking it to read a file. Measured on one trivial task: 17s
894        // against 73s, because prose costs a tool round-trip before the model
895        // has even seen its instructions. It is also the form rvpm proved.
896        let agy_prompt = agy_rw
897            .argv
898            .iter()
899            .position(|a| a == "-p")
900            .map(|i| agy_rw.argv[i + 1].clone())
901            .expect("agy takes its prompt with -p");
902        assert!(
903            agy_prompt.starts_with('@'),
904            "agy must get a file reference, got {agy_prompt:?}"
905        );
906        assert!(
907            !agy_prompt.contains("Read the file at"),
908            "the prose pointer is for CLIs with no file syntax"
909        );
910
911        // opencode is the exception: `--auto` also gates reads, so withholding
912        // it silently drops the seat out of the panel. Verified against the CLI
913        // — a read-only judge failed with "the user rejected permission to use
914        // this specific tool call" while trying to open its own prompt.
915        for allow_write in [false, true] {
916            assert!(
917                plan_for(AgentKind::Opencode, &seat, allow_write)
918                    .argv
919                    .iter()
920                    .any(|a| a == "--auto"),
921                "opencode needs --auto even to read (allow_write = {allow_write})"
922            );
923        }
924    }
925
926    #[test]
927    fn captured_sessions_resume_only_once_reported() {
928        let mut seat = SeatState::new("impl-A", "a", 7);
929        seat.turns = 1;
930        for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
931            assert!(!has_session(kind, &seat, true));
932            let p = plan_for(kind, &seat, true);
933            assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
934        }
935
936        seat.captured_session = Some("sid".to_owned());
937        assert!(has_session(AgentKind::Opencode, &seat, true));
938        assert!(
939            plan_for(AgentKind::Opencode, &seat, true)
940                .argv
941                .windows(2)
942                .any(|w| w == ["-s", "sid"])
943        );
944        assert!(
945            plan_for(AgentKind::Antigravity, &seat, true)
946                .argv
947                .windows(2)
948                .any(|w| w == ["--conversation", "sid"])
949        );
950    }
951
952    #[test]
953    fn sessions_disabled_never_resumes() {
954        let mut seat = SeatState::new("impl-A", "a", 7);
955        seat.turns = 3;
956        seat.captured_session = Some("sid".to_owned());
957        for kind in [
958            AgentKind::Claude,
959            AgentKind::Opencode,
960            AgentKind::Antigravity,
961        ] {
962            assert!(!has_session(kind, &seat, false));
963        }
964    }
965
966    #[test]
967    fn long_prompts_never_reach_argv_for_file_delivery_clis() {
968        let seat = SeatState::new("judge-1", "a", 7);
969        for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
970            let p = plan_for(kind, &seat, false);
971            assert!(
972                p.argv.iter().all(|a| a != "do the thing"),
973                "{kind:?} put the prompt on the command line"
974            );
975            assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
976        }
977        // agy has no text stdin, so its `-p` must always carry something.
978        let p = plan_for(AgentKind::Antigravity, &seat, false);
979        let at = p.argv.iter().position(|a| a == "-p").unwrap();
980        assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
981        assert!(p.stdin.is_none());
982    }
983
984    #[test]
985    fn agy_print_timeout_tracks_the_node_budget() {
986        let seat = SeatState::new("impl-A", "a", 7);
987        let p = build_command(
988            &spec(AgentKind::Antigravity, None),
989            &seat,
990            &Invocation {
991                cwd: Path::new("."),
992                prompt: "p",
993                timeout: Duration::from_secs(3600),
994                allow_write: true,
995                sessions: true,
996                artifacts: Path::new("/art"),
997                stem: "t",
998                run: "test-run",
999                node: "test",
1000            },
1001            Path::new("/art/p.md"),
1002        )
1003        .unwrap();
1004        assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
1005    }
1006
1007    #[test]
1008    fn command_agents_get_placeholders_substituted() {
1009        let seat = SeatState::new("impl-A", "a", 7);
1010        let p = plan_for(AgentKind::Command, &seat, true);
1011        assert_eq!(p.argv[0], "echo");
1012        assert_eq!(p.argv[1], "impl-A");
1013        assert_eq!(p.stdin.as_deref(), Some("do the thing"));
1014    }
1015
1016    #[test]
1017    fn claude_rate_limit_is_detected_and_reset_read_when_present() {
1018        // The exact shape observed in the wild (run 20260831-031005-ae94).
1019        let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
1020                        "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
1021                        "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
1022        let out = extract(AgentKind::Claude, stdout);
1023        let quota = out.quota.as_ref().expect("rate limit must be detected");
1024        assert_eq!(
1025            quota.reset.as_deref(),
1026            Some("4:50am (Asia/Tokyo)"),
1027            "reset time read from the body"
1028        );
1029    }
1030
1031    #[test]
1032    fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
1033        let out = extract(
1034            AgentKind::Claude,
1035            r#"{"is_error":true,"result":"session limit reached"}"#,
1036        );
1037        let quota = out.quota.expect("rate limit detected without a reset");
1038        assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
1039    }
1040
1041    #[test]
1042    fn ordinary_failures_are_never_quota() {
1043        // A normal failed claude call (is_error with a different message).
1044        let claude_fail = extract(
1045            AgentKind::Claude,
1046            r#"{"is_error":true,"result":"account does not exist"}"#,
1047        );
1048        assert!(claude_fail.quota.is_none());
1049
1050        // A command agent that exits 1 with plain text.
1051        let cmd_fail = extract(AgentKind::Command, "boom");
1052        assert!(cmd_fail.quota.is_none());
1053
1054        // A successful call is not quota even if it mentions the phrase.
1055        let success = extract(
1056            AgentKind::Command,
1057            r#"{"is_error":false,"result":"session limit is fine"}"#,
1058        );
1059        assert!(success.quota.is_none());
1060    }
1061
1062    #[test]
1063    fn command_agent_can_carry_the_claude_quota_shape() {
1064        let out = extract(
1065            AgentKind::Command,
1066            r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
1067        );
1068        assert!(
1069            out.quota.is_some(),
1070            "a wrapper emitting the claude shape counts as quota"
1071        );
1072    }
1073
1074    #[test]
1075    fn claude_json_result_is_extracted() {
1076        let out = extract(
1077            AgentKind::Claude,
1078            r#"{"result":"all done","session_id":"abc","is_error":false}"#,
1079        );
1080        assert_eq!(out.text, "all done");
1081        assert_eq!(out.session.as_deref(), Some("abc"));
1082        assert_eq!(out.status.as_deref(), Some("success"));
1083    }
1084
1085    #[test]
1086    fn opencode_event_stream_is_concatenated() {
1087        let stream = concat!(
1088            r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
1089            "\n",
1090            r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
1091            "\n",
1092            "garbage line\n",
1093            r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
1094            "\n"
1095        );
1096        let out = extract(AgentKind::Opencode, stream);
1097        assert_eq!(out.text, "first\nsecond");
1098        assert_eq!(out.session.as_deref(), Some("ses_1"));
1099    }
1100
1101    #[test]
1102    fn agy_json_survives_a_leading_warning_line() {
1103        let stdout = concat!(
1104            "warning: --mode plan has no effect while slash commands are disabled.\n",
1105            r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
1106            "\n"
1107        );
1108        let out = extract(AgentKind::Antigravity, stdout);
1109        assert_eq!(out.text, "persimmon");
1110        assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
1111        assert_eq!(out.status.as_deref(), Some("SUCCESS"));
1112    }
1113
1114    /// Run 26c7's candidate B, verbatim from `artifacts/impl-B.out`.
1115    ///
1116    /// The seat read as an empty candidate. It was seven minutes of work and
1117    /// 14,267 output tokens, billed, that the CLI then declined to hand over.
1118    /// Five such candidates are why `agy` reads as 0 wins in 4 entries, and
1119    /// that number has twice been used to argue the seat out of the roster.
1120    const AGY_DROPPED: &str = concat!(
1121        r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
1122        r#""response":"","error":"the connection to the agent was interrupted before "#,
1123        r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
1124        r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
1125        r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
1126        r#""total_tokens":274380}}"#
1127    );
1128
1129    #[test]
1130    fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
1131        let out = extract(AgentKind::Antigravity, AGY_DROPPED);
1132        let dropped = out.dropped.expect("recognised as undelivered work");
1133        assert_eq!(dropped.output_tokens, 14267);
1134        assert!(
1135            dropped.why.contains("subscriber fell behind"),
1136            "the CLI's own words are kept for the record: {}",
1137            dropped.why
1138        );
1139        // And the conversation is still there to resume, which is the whole
1140        // reason this is worth re-asking where a quota is not.
1141        assert_eq!(
1142            out.session.as_deref(),
1143            Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
1144        );
1145        assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
1146    }
1147
1148    #[test]
1149    fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
1150        // No usage at all: the agent never got going, so there is nothing in
1151        // the conversation to resume and nothing was billed. Treating this as
1152        // undelivered work would buy a second call for no reason.
1153        let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
1154        assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
1155
1156        // Produced tokens, but it did answer - so there is something to read
1157        // and the status is not our business.
1158        let answered = concat!(
1159            r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
1160            r#""usage":{"output_tokens":10}}"#
1161        );
1162        assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
1163
1164        // A success is a success.
1165        let ok = concat!(
1166            r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
1167            r#""usage":{"output_tokens":10}}"#
1168        );
1169        assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
1170    }
1171
1172    #[test]
1173    fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
1174        let out = AgentOutput {
1175            text: String::new(),
1176            exit_code: Some(1),
1177            timed_out: false,
1178            duration_ms: 431_194,
1179            artifacts: Vec::new(),
1180            quota: None,
1181            dropped: Some(Dropped {
1182                why: "subscriber fell behind updates".to_owned(),
1183                output_tokens: 14267,
1184            }),
1185        };
1186        assert!(!out.usable());
1187        assert!(out.work_undelivered());
1188        // The distinction the retry policy rests on: a quota fails the same way
1189        // until it resets, an abandoned conversation can be picked up.
1190        assert!(!out.quota_exhausted());
1191    }
1192
1193    #[test]
1194    fn non_json_stdout_falls_back_to_raw_text() {
1195        let out = extract(AgentKind::Antigravity, "plain answer\n");
1196        assert_eq!(out.text, "plain answer");
1197        assert!(out.session.is_none());
1198    }
1199
1200    #[tokio::test]
1201    async fn command_agent_round_trip_writes_artifacts() {
1202        let dir = tempfile::tempdir().unwrap();
1203        let art = dir.path().join("artifacts");
1204        let mut seat = SeatState::new("impl-A", "a", 7);
1205        let mut s = spec(AgentKind::Command, None);
1206        s.command = vec!["echo".to_owned(), "hello {label}".to_owned()];
1207        let out = invoke(
1208            &s,
1209            &mut seat,
1210            &Invocation {
1211                cwd: dir.path(),
1212                prompt: "unused",
1213                timeout: Duration::from_secs(30),
1214                allow_write: true,
1215                sessions: true,
1216                artifacts: &art,
1217                stem: "impl-A",
1218                run: "test-run",
1219                node: "test",
1220            },
1221        )
1222        .await
1223        .unwrap();
1224        assert!(out.usable(), "{out:?}");
1225        assert!(out.text.contains("hello impl-A"), "{}", out.text);
1226        assert_eq!(seat.turns, 1);
1227        assert!(art.join("impl-A.prompt.md").is_file());
1228        assert!(art.join("impl-A.out").is_file());
1229    }
1230
1231    #[tokio::test]
1232    async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
1233        let dir = tempfile::tempdir().unwrap();
1234        let mut seat = SeatState::new("impl-A", "a", 7);
1235        let mut s = spec(AgentKind::Command, None);
1236        // `echo` never reads stdin, so an inline write_all would block once the
1237        // OS pipe buffer filled — long before the process could be waited on.
1238        s.command = vec!["echo".to_owned(), "done".to_owned()];
1239        let big = "x".repeat(1_000_000);
1240        let out = invoke(
1241            &s,
1242            &mut seat,
1243            &Invocation {
1244                cwd: dir.path(),
1245                prompt: &big,
1246                timeout: Duration::from_secs(60),
1247                allow_write: true,
1248                sessions: true,
1249                artifacts: &dir.path().join("artifacts"),
1250                stem: "big",
1251                run: "test-run",
1252                node: "test",
1253            },
1254        )
1255        .await
1256        .unwrap();
1257        assert!(out.usable(), "{out:?}");
1258        assert_eq!(out.text, "done");
1259    }
1260
1261    #[tokio::test]
1262    async fn timeout_is_reported_not_hung() {
1263        let dir = tempfile::tempdir().unwrap();
1264        let mut seat = SeatState::new("impl-A", "a", 7);
1265        let mut s = spec(AgentKind::Command, None);
1266        s.command = vec!["sleep".to_owned(), "30".to_owned()];
1267        let out = invoke(
1268            &s,
1269            &mut seat,
1270            &Invocation {
1271                cwd: dir.path(),
1272                prompt: "unused",
1273                timeout: Duration::from_millis(300),
1274                allow_write: true,
1275                sessions: true,
1276                artifacts: &dir.path().join("artifacts"),
1277                stem: "slow",
1278                run: "test-run",
1279                node: "test",
1280            },
1281        )
1282        .await
1283        .unwrap();
1284        assert!(out.timed_out);
1285        assert!(!out.usable());
1286    }
1287
1288    #[tokio::test]
1289    async fn a_timeout_keeps_what_the_agent_had_already_printed() {
1290        // The old implementation cancelled `wait_with_output`, which dropped
1291        // the buffers it owned, so `<stem>.out` was written empty on every
1292        // timeout. "It printed nothing" and "we discarded what it printed"
1293        // looked identical on disk — and one real hour-long stall was
1294        // diagnosed wrongly twice because of it.
1295        let dir = tempfile::tempdir().unwrap();
1296        let artifacts = dir.path().join("artifacts");
1297        let mut seat = SeatState::new("impl-A", "a", 7);
1298        let mut s = spec(AgentKind::Command, None);
1299        s.command = vec![
1300            "sh".to_owned(),
1301            "-c".to_owned(),
1302            "echo i-said-something; sleep 30".to_owned(),
1303        ];
1304        let out = invoke(
1305            &s,
1306            &mut seat,
1307            &Invocation {
1308                cwd: dir.path(),
1309                prompt: "unused",
1310                // Wide enough to cover process-spawn latency inside a loaded
1311                // parallel test run, not merely the echo. At two seconds this
1312                // passed alone and failed in the full suite, which is a dice
1313                // roll rather than a test.
1314                timeout: Duration::from_secs(10),
1315                allow_write: true,
1316                sessions: true,
1317                artifacts: &artifacts,
1318                stem: "chatty",
1319                run: "test-run",
1320                node: "test",
1321            },
1322        )
1323        .await
1324        .unwrap();
1325
1326        assert!(out.timed_out, "{out:?}");
1327        assert!(!out.usable(), "a cut-off answer is still not an answer");
1328        let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
1329        assert!(
1330            recorded.contains("i-said-something"),
1331            "the artifact must keep what arrived before the kill, got {recorded:?}"
1332        );
1333        assert!(
1334            out.text.contains("i-said-something"),
1335            "and the graph must be able to see it too, got {:?}",
1336            out.text
1337        );
1338    }
1339
1340    #[test]
1341    fn missing_programs_reports_command_binaries() {
1342        let mut s = spec(AgentKind::Command, None);
1343        s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
1344        assert_eq!(
1345            missing_programs(&[s]),
1346            ["definitely-not-a-real-binary-xyz".to_owned()]
1347        );
1348    }
1349}