Skip to main content

car_server_core/coder/
external_loop.rs

1//! Delegation to an external agentic CLI (Claude Code, Codex, Gemini).
2//!
3//! The CLI does the coding inside the worktree; **CAR keeps the verdict**:
4//! after every invocation the runtime re-runs the outcome contract itself
5//! through the policy-gated shell tool. A CLI claiming success doesn't matter
6//! — the checks do.
7//!
8//! When the daemon's MCP listener is bound, its URL is threaded into
9//! [`InvokeOptions::mcp_endpoint`] so the CLI's **CAR-namespace** tool calls
10//! (`memory_*`, `verify`, `skill_*`) route back through car-server's policy +
11//! memgine — gated and audited. The CLI's own **built-in** tools (Edit, Bash)
12//! still run with the CLI's permissions inside the worktree: that is the
13//! residual Phase 2 stage-4b upstream limitation. The pinned `cwd`, the
14//! contract re-evaluation, and the merge approval gate remain the containment
15//! for those built-ins until tool round-trip governance lands in
16//! `car-external-agents`.
17
18use std::collections::HashMap;
19use std::sync::atomic::Ordering;
20use std::sync::Arc;
21
22use async_trait::async_trait;
23use car_external_agents::{InvokeError, InvokeOptions, InvokeResult, StreamEventEmitter};
24
25use super::budget::SessionDeadline;
26use super::contract::{evaluate_contract_with_baselines, CheckResult, OutcomeContract};
27use super::native_loop::{
28    primary_failure, record_recurrence, recurrence_notice, LoopFailure, LoopOutcome,
29};
30use super::session::{CancelFlag, CoderEventKind, EventSink};
31use super::shell_tool::WorktreeExecutor;
32
33/// Tuning for external delegation.
34#[derive(Debug, Clone)]
35pub struct ExternalLoopConfig {
36    /// Per-invocation model-turn cap (maps to the CLI's `--max-turns`).
37    pub max_turns: Option<u32>,
38    /// Per-invocation wall-clock budget (runner clamps to 1h).
39    pub timeout_secs: Option<u64>,
40    /// Fresh repair invocations after a red first pass (the stream-json
41    /// protocol has no session resume yet, so repairs re-state the task plus
42    /// the failing-check output).
43    ///
44    /// This is the **hypothesis** budget: each one buys another attempt at
45    /// being right. It is deliberately not spent on transport failures — see
46    /// `transient_retries`.
47    ///
48    /// **Defaults to 2 because recurrence escalation needs it.** Round 1
49    /// establishes a failure signature, round 2 is the first that can repeat it,
50    /// and only round 3 can be told it did. At 1 the loop still *detects* the
51    /// repeat, but the session ends before the feedback carrying that news
52    /// reaches anyone — and since `rpc` is the only construction site and takes
53    /// `..Default::default()`, a default of 1 made the escalation unreachable in
54    /// every shipped configuration.
55    ///
56    /// The cost is smaller than it looks: worst-case invocations are
57    /// `max_hypotheses + transient_retries`, so this moves 3 -> 4, about +33%,
58    /// and only on sessions that are already failing.
59    pub repair_invokes: u32,
60    /// Re-invocations after the CLI process itself died mid-run (timeout or
61    /// I/O) with the contract still red.
62    ///
63    /// Separate from `repair_invokes` because it buys a different thing: an
64    /// **availability** retry, not a new hypothesis. Sharing one counter means a
65    /// single flaky timeout eats a replan the coder needed for an actual
66    /// hypothesis — the difference between a session that recovers and one that
67    /// silently gives up on a task it was about to finish.
68    pub transient_retries: u32,
69    /// Pin the external CLI's backbone (`coder.start`'s `model`). `None` = the
70    /// CLI's own default.
71    ///
72    /// The paired A/B only measures the *harness* when both arms share a
73    /// backbone; this is the external half of that invariant (the native half is
74    /// `NativeLoopConfig.model`). Before this existed the pin reached only the
75    /// native loop, so "both arms on gpt-5.5" was an unverified assumption.
76    pub model: Option<String>,
77    /// The session's absolute deadline, SHARED with every other rung of the
78    /// fallback ladder. See [`super::budget`] for why this is a handle and not
79    /// a value.
80    pub deadline: Arc<SessionDeadline>,
81    /// The session-start baseline captures differential checks compare against
82    /// (car#1067). Empty when the contract declares none; differential checks
83    /// fail closed under an empty map.
84    pub baseline_captures: super::contract::BaselineCaptures,
85}
86
87impl Default for ExternalLoopConfig {
88    fn default() -> Self {
89        Self {
90            max_turns: Some(50),
91            timeout_secs: Some(1800),
92            repair_invokes: 2,
93            transient_retries: 1,
94            model: None,
95            deadline: SessionDeadline::shared_default(),
96            baseline_captures: super::contract::BaselineCaptures::new(),
97        }
98    }
99}
100
101/// The CLI seam: one invocation of an external agent.
102///
103/// Exists for the same reason `native_loop` takes a `&dyn TurnGenerator` — the
104/// loop's interesting behavior (budget accounting, retry-vs-replan, round
105/// counting) is decided by what comes back from here, and none of it is
106/// testable while the call is hard-wired to a real subprocess.
107#[async_trait]
108pub trait CliInvoker: Send + Sync {
109    async fn invoke(
110        &self,
111        agent_id: &str,
112        task: &str,
113        opts: InvokeOptions,
114        emitter: StreamEventEmitter,
115    ) -> Result<InvokeResult, InvokeError>;
116}
117
118/// The production invoker: a real external CLI subprocess.
119pub struct LiveInvoker;
120
121#[async_trait]
122impl CliInvoker for LiveInvoker {
123    async fn invoke(
124        &self,
125        agent_id: &str,
126        task: &str,
127        opts: InvokeOptions,
128        emitter: StreamEventEmitter,
129    ) -> Result<InvokeResult, InvokeError> {
130        car_external_agents::invoke_with_emitter(agent_id, task, opts, Some(emitter)).await
131    }
132}
133
134/// The task text handed to the CLI: intent + contract + ground rules.
135fn build_task(intent: &str, contract: &OutcomeContract, feedback: Option<&str>) -> String {
136    let mut task = format!(
137        "{intent}\n\n\
138         OUTCOME CONTRACT — your work is verified by running these checks at the repository \
139         root; all must pass:\n{}\n\
140         Ground rules:\n\
141         - Work only inside the current directory (an isolated git worktree).\n\
142         - Do NOT git commit, push, or touch remotes; the runtime owns version control.\n\
143         - Run the checks yourself before finishing.\n",
144        contract.render()
145    );
146    if let Some(fb) = feedback {
147        task.push_str(&format!(
148            "\nA previous attempt left these checks FAILING — fix the code so they pass:\n{fb}"
149        ));
150    }
151    task
152}
153
154/// The per-invocation options handed to the runner. The `mcp_endpoint`, when
155/// set, routes the CLI's CAR-namespace tool calls through the daemon's policy +
156/// memgine; `allowed_tools: None` leaves the CLI's own built-in tools on their
157/// default (ungoverned) policy — see the module docs.
158fn build_invoke_opts(
159    executor: &WorktreeExecutor,
160    cfg: &ExternalLoopConfig,
161    mcp_endpoint: Option<&str>,
162    mcp_config_dir: Option<&std::path::Path>,
163) -> InvokeOptions {
164    InvokeOptions {
165        cwd: Some(executor.worktree().to_path_buf()),
166        allowed_tools: None, // the CLI's default policy; see module docs
167        max_turns: cfg.max_turns,
168        // Clamped to what the SESSION has left, not just this invocation's own
169        // budget. Admission alone grants a whole round, so a hypothesis let in
170        // just under the ceiling could otherwise run its full 1800s past it —
171        // a ceiling exceeded by 50% is not a ceiling. This is not interruption:
172        // the round simply starts with a shorter clock, and a CLI that hits its
173        // own timeout already flows through `Infrastructure` ->
174        // `evaluate_contract`, so nothing goes unjudged.
175        timeout_secs: match (cfg.timeout_secs, cfg.deadline.remaining_secs()) {
176            (Some(own), Some(left)) => Some(own.min(left)),
177            (own, None) => own,
178            (None, left) => left,
179        },
180        // The external half of the A/B's same-backbone invariant.
181        model: cfg.model.clone(),
182        // Gate + audit the CLI's CAR-namespace tool calls through the daemon
183        // when its MCP listener is bound; None degrades cleanly.
184        mcp_endpoint: mcp_endpoint.map(String::from),
185        // Where that config file is allowed to land. The daemon supplies a
186        // directory it owns and has verified, so the file stops following a
187        // `TMPDIR` nobody checked (car#1534). `None` keeps `$TMPDIR`.
188        mcp_config_dir: mcp_config_dir.map(std::path::Path::to_path_buf),
189        ..Default::default()
190    }
191}
192
193/// Run the external engine to completion, cancellation, or exhaustion.
194///
195/// The `cancel` flag is checked between invocations only, but an in-flight CLI
196/// is NOT left running: `rpc::cancel_session` aborts the task handle, which
197/// drops this future along with the `Child`, and every adapter sets
198/// `kill_on_drop(true)` (with a Windows `JobObject` for the Node grandchildren).
199/// Enforcement lives one level up; the flag here is belt-and-braces, which is
200/// why threading `invoke_with_emitter_and_cancel` through [`CliInvoker`] would
201/// be tidier rather than more correct. The classification
202/// below already handles [`LoopFailure::Cancelled`] as its own terminal so that
203/// change does not need to revisit the control flow.
204///
205/// ## Why the contract is evaluated before a failure is classified
206///
207/// Every path that got as far as launching the CLI evaluates the contract,
208/// including one that ended in a timeout or a broken stream. A 30-minute
209/// timeout that fires after the CLI has already edited fifteen files says
210/// nothing about whether those edits satisfy the contract — and a loop that
211/// returns terminal without asking has let the *transport* pronounce the
212/// verdict, which is precisely what this module exists to prevent. The state
213/// under judgement is the worktree, not the process that was writing to it.
214///
215/// Only three conditions skip evaluation, all because no work can exist yet:
216/// the engine never started ([`LoopFailure::EngineUnavailable`]), its
217/// environment was too broken to hand it the task
218/// ([`LoopFailure::Configuration`], car#1534), and the user cancelled
219/// ([`LoopFailure::Cancelled`]).
220pub async fn run_external_loop(
221    invoker: &dyn CliInvoker,
222    agent_id: &str,
223    intent: &str,
224    contract: &OutcomeContract,
225    executor: &WorktreeExecutor,
226    sink: &Arc<EventSink>,
227    cancel: &CancelFlag,
228    cfg: &ExternalLoopConfig,
229    // Daemon MCP URL, when bound. Routes the CLI's CAR-namespace tool calls
230    // through the daemon's policy + memgine. `None` degrades cleanly.
231    mcp_endpoint: Option<&str>,
232    // Directory the adapter writes that MCP config into. Travels beside the
233    // endpoint because it is only meaningful when one is set. `None` keeps the
234    // adapter's `$TMPDIR` default (car#1534).
235    mcp_config_dir: Option<&std::path::Path>,
236) -> LoopOutcome {
237    let max_hypotheses = 1 + cfg.repair_invokes;
238    let mut feedback: Option<String> = None;
239    let mut last_results = Vec::new();
240    // Hypotheses spent. Bumped only by a replan, so a transport retry buys
241    // another invocation without costing an attempt at being right.
242    let mut hypothesis = 1u32;
243    let mut transient_budget = cfg.transient_retries;
244    // Contract-evaluation rounds — what `LoopOutcome.iterations` means to its
245    // real consumer, `session.iterations` as reported by `coder.get`. A retry
246    // evaluates the contract, so it counts here even though it costs no
247    // hypothesis. (`ab::ArmOutcome.iterations` documents the same meaning but
248    // `coder_ab` hardcodes 0 today — `car code` emits no machine-readable count.)
249    let mut rounds = 0u32;
250    // Set when the previous pass was a transport retry: the hypothesis banner
251    // must not fire twice for one hypothesis.
252    let mut retrying = false;
253    // Failure signatures seen across hypotheses, so a repair that lands the
254    // identical failure is told so rather than handed the same text again.
255    let mut seen_sigs: HashMap<String, u32> = HashMap::new();
256    // This loop's clock starts here, before the first invocation.
257
258    // Metered spend across every invocation. `None` until something reports a
259    // figure, so "unmetered" stays distinguishable from "$0.00".
260    let mut spent_usd: Option<f64> = None;
261
262    loop {
263        if cancel.load(Ordering::SeqCst) {
264            return LoopOutcome::lost(
265                LoopFailure::Cancelled,
266                Some("cancelled".into()),
267                rounds,
268                last_results,
269            )
270            .with_cost(spent_usd);
271        }
272        // Admission, not interruption. A retry is admitted too — it is the same
273        // hypothesis re-run, and denying only fresh hypotheses would let a
274        // flaky CLI run past the ceiling indefinitely.
275        if let Some(reason) = cfg.deadline.admit() {
276            sink.emit(CoderEventKind::BudgetExhausted {
277                reason: reason.clone(),
278                elapsed_secs: cfg.deadline.elapsed_secs(),
279                iterations: rounds,
280            });
281            return LoopOutcome::lost(
282                LoopFailure::BudgetExhausted,
283                Some(reason),
284                rounds,
285                last_results,
286            )
287            .with_cost(spent_usd);
288        }
289        if !retrying {
290            sink.emit(CoderEventKind::IterationStarted {
291                n: hypothesis,
292                max: max_hypotheses,
293            });
294        }
295        retrying = false;
296
297        let task = build_task(intent, contract, feedback.as_deref());
298        let opts = build_invoke_opts(executor, cfg, mcp_endpoint, mcp_config_dir);
299
300        let emitter_sink = sink.clone();
301        let emitter: StreamEventEmitter = Arc::new(move |event| {
302            if let Ok(raw) = serde_json::to_value(&event) {
303                emitter_sink.emit(CoderEventKind::ExternalEvent { raw });
304            }
305        });
306
307        // What the invocation itself reported, before the contract has spoken.
308        // `Ok(None)` = the CLI ran clean; `Ok(Some(msg))` = it ran and reported
309        // its own error; `Err(class)` = it died, and how.
310        let invocation: Result<Option<String>, (LoopFailure, String)> =
311            match invoker.invoke(agent_id, &task, opts, emitter).await {
312                Ok(result) if result.is_error => {
313                    record_spend(&mut spent_usd, result.total_cost_usd);
314                    let msg = result.error.unwrap_or_else(|| "unknown".into());
315                    sink.emit(CoderEventKind::Error {
316                        message: format!("external agent '{agent_id}' reported an error: {msg}"),
317                    });
318                    Ok(Some(msg))
319                }
320                Ok(result) => {
321                    record_spend(&mut spent_usd, result.total_cost_usd);
322                    Ok(None)
323                }
324                Err(e) => Err((classify_invoke_error(&e), e.to_string())),
325            };
326
327        // Two failures make evaluation meaningless rather than merely
328        // unnecessary: nothing ran, so no work can exist to judge. Matched
329        // variant-by-variant — a wildcard here would silently hand a future
330        // variant the string `"cancelled"` and the wrong terminal state.
331        let terminal = match &invocation {
332            // Prefix kept verbatim: `rpc` branches on the typed variant now, but
333            // `car-cli`'s A/B still scrapes this text out-of-process.
334            Err((LoopFailure::EngineUnavailable, msg)) => Some((
335                LoopFailure::EngineUnavailable,
336                format!("external agent '{agent_id}' failed: {msg}"),
337            )),
338            // A broken ENVIRONMENT (`InvokeError::Setup`): the CLI never got
339            // the task, so there is nothing to judge — and, unlike
340            // `EngineUnavailable`, nothing another engine could do better,
341            // since it would run in the same broken environment (car#1534).
342            // The prefix is the SAME as the arm above, and deliberately so:
343            // `car-cli`'s A/B excludes a never-attempted cell by scraping
344            // `"external agent '"` out of process (`coder_ab::INFRA_MARKERS`),
345            // and that marker is now the only thing covering this case — the
346            // typed `failure_kind:` path does not, because `kind_is_infra`
347            // lists `infrastructure`/`auth_required` and this renders as
348            // `configuration`. Changing this wording silently re-scores a
349            // broken environment as a genuine task loss.
350            Err((LoopFailure::Configuration, msg)) => Some((
351                LoopFailure::Configuration,
352                format!("external agent '{agent_id}' failed: {msg}"),
353            )),
354            Err((LoopFailure::Cancelled, _)) => {
355                Some((LoopFailure::Cancelled, "cancelled".to_string()))
356            }
357            // Of these, `classify_invoke_error` produces only `Infrastructure`
358            // (`Timeout`/`Io`), and it belongs here on purpose: those two had
359            // the task, so the worktree may hold edits and the contract — not
360            // the transport — gets the verdict.
361            //
362            // The rest are named only to keep the match wildcard-free, so a new
363            // variant fails to compile rather than silently acquiring a
364            // terminal it does not mean.
365            //
366            // `NeedsAuth` is native-loop-only: the external arm's credentials
367            // belong to the CLI it shells, so CAR has nothing to re-authenticate
368            // on its behalf and no standing to pause the run waiting for it.
369            Err((LoopFailure::Infrastructure, _))
370            | Err((LoopFailure::NeedsAuth, _))
371            | Err((LoopFailure::Execution, _))
372            | Err((LoopFailure::Verification, _))
373            | Err((LoopFailure::BudgetExhausted, _))
374            | Ok(_) => None,
375        };
376        if let Some((failure, error)) = terminal {
377            return LoopOutcome::lost(failure, Some(error), rounds, last_results)
378                .with_cost(spent_usd);
379        }
380
381        // CAR's verdict, not the CLI's — and not the transport's.
382        last_results =
383            evaluate_contract_with_baselines(contract, executor, sink, &cfg.baseline_captures)
384                .await;
385        rounds += 1;
386        if last_results.iter().all(|r| r.passed) {
387            return LoopOutcome::green(rounds, last_results).with_cost(spent_usd);
388        }
389
390        // Red. Now — and only now — the failure has a class.
391        let failure = match &invocation {
392            Err((class, _)) => *class,
393            Ok(Some(_)) => LoopFailure::Execution,
394            Ok(None) => LoopFailure::Verification,
395        };
396        if failure == LoopFailure::Infrastructure && transient_budget > 0 {
397            // Availability, not correctness: re-invoke against the worktree as
398            // it now stands, carrying the failing checks so the retry is
399            // better-informed than the attempt it replaces.
400            transient_budget -= 1;
401            retrying = true;
402            sink.emit(CoderEventKind::InvocationRetried {
403                hypothesis,
404                reason: match &invocation {
405                    Err((_, msg)) => msg.clone(),
406                    Ok(_) => String::new(),
407                },
408                retries_remaining: transient_budget,
409            });
410            continue;
411        }
412
413        if hypothesis >= max_hypotheses {
414            // An exhausted Infrastructure failure must still LOOK like one.
415            // `car-cli`'s A/B splits infra out of the scored denominator by
416            // scraping this string (`coder_ab::INFRA_MARKERS`); leaving it
417            // `None` let `rpc` substitute "contract not satisfied after N
418            // iteration(s)", which scores a dead transport as a genuine task
419            // loss and quietly biases the arm it belongs to.
420            let error = match (failure, &invocation) {
421                (LoopFailure::Infrastructure, Err((_, msg))) => {
422                    Some(format!("external agent '{agent_id}' failed: {msg}"))
423                }
424                _ => None,
425            };
426            // Returns BEFORE the feedback below is built: nothing will read it,
427            // and rendering it means formatting every failing check's 4KB tail
428            // on the last round of every failing session. Ordering carries the
429            // invariant so a reader need not hold it: feedback is only built for
430            // a round that will actually happen.
431            return LoopOutcome::lost(failure, error, rounds, last_results).with_cost(spent_usd);
432        }
433
434        // Another round WILL happen, so build its handoff.
435        let check_feedback = render_check_failures(&last_results);
436        feedback = Some(match &invocation {
437            // The CLI's own error is context the checks cannot supply.
438            Ok(Some(msg)) => {
439                format!("A previous attempt reported this error:\n{msg}\n\n{check_feedback}")
440            }
441            Err((_, msg)) => format!(
442                "A previous attempt was cut short ({msg}); its work may be partially applied.\n\n\
443                 {check_feedback}"
444            ),
445            // Only a clean run earns a recurrence. NOT because the other cases
446            // were "cut short" — an `is_error` CLI may well have run to
447            // completion — but because `InvokeResult.is_error` is a
448            // heterogeneous bucket: it covers a non-zero exit, an empty answer,
449            // and "produced no agent_message" alike, so it cannot distinguish
450            // "hit a real wall" from "never produced anything". Counting the
451            // latter would inflate the tally against an attempt that did not
452            // happen. Accepted cost: a signature first seen on an `Execution`
453            // round is never recorded, so its count stays one low all session.
454            //
455            // The count is computed here, in the one arm that consumes it, so
456            // the policy is stated once. Hoisting it into a separate `if`
457            // duplicates this condition and silently zeroes any escalation a
458            // future arm might add.
459            Ok(None) => {
460                match record_recurrence(&mut seen_sigs, primary_failure(&last_results).as_ref()) {
461                    0 => check_feedback,
462                    n => format!("{check_feedback}\n\n{}", recurrence_notice(n)),
463                }
464            }
465        });
466
467        hypothesis += 1;
468    }
469}
470
471/// Map a transport-level error onto the outcome it implies.
472///
473/// The dividing question is whether the agent ever received the task, because
474/// that is what decides if any work can exist to judge:
475/// - `Spawn` / `Setup` — the process never started, or started and never got
476///   the prompt (pipes, stdin, MCP config). No work exists, so both end the
477///   attempt without consulting the contract. They differ in whether another
478///   engine may be tried; see the `Spawn`/`Setup` split below.
479/// - `Timeout` / `Io` — the agent had the task and the run died underneath it.
480///   Edits may be on disk, so the contract gets consulted and the same
481///   hypothesis may be retried.
482/// - `Cancelled` — the human stopped it. Never substitute another engine.
483///
484/// `Setup` exists because most of what used to be `Io` was this case: writing
485/// the MCP tempfile, acquiring stdout, delivering the prompt. Calling those
486/// retryable meant re-running a full contract evaluation against an untouched
487/// worktree and then declining the fallback that used to fire.
488///
489/// `Spawn` and `Setup` part company here (car#1534). Both end the attempt
490/// before any work exists, but they answer different questions and the fallback
491/// policy turns on the difference:
492/// - `Spawn` — the engine cannot run *here*: not installed, not detected, not
493///   executable, unknown adapter, `ENOENT`. Another engine is a reasonable
494///   substitute, so this stays [`LoopFailure::EngineUnavailable`], the one
495///   class `rpc::fallback_allowed` will fall back on.
496/// - `Setup` — the engine is fine and the *environment* is broken: the MCP
497///   config could not be written, stdin/stdout could not be acquired. Running a
498///   second engine in the same broken environment is not a recovery, it is a
499///   silent substitution of the work the operator asked for — which is exactly
500///   what car#1534 filed. So it is [`LoopFailure::Configuration`]: terminal,
501///   never a fallback candidate, and rendered to clients as `"configuration"`
502///   by `rpc::failure_kind_for`.
503///
504/// `Configuration` rather than `Infrastructure` deliberately: `Infrastructure`
505/// is this loop's **non-terminal, retried** class (the terminal match below
506/// leaves it to the contract, and the transient-retry branch re-invokes on it),
507/// because a `Timeout` or an `Io` may well have left edits on disk worth
508/// judging. A broken environment left nothing, so retrying it only spends a
509/// contract evaluation against an untouched worktree to reach the same answer.
510fn classify_invoke_error(e: &car_external_agents::InvokeError) -> LoopFailure {
511    use car_external_agents::InvokeError as E;
512    match e {
513        E::Spawn(_) => LoopFailure::EngineUnavailable,
514        E::Setup(_) => LoopFailure::Configuration,
515        E::Timeout(_) | E::Io(_) => LoopFailure::Infrastructure,
516        E::Cancelled => LoopFailure::Cancelled,
517    }
518}
519
520/// Fold one invocation's reported spend into the session total.
521///
522/// Stays `None` until a provider actually reports a figure, so a native run (or
523/// a CLI that reports nothing) is recorded as *unknown* rather than as $0.00 —
524/// the conflation that made `ab::ArmOutcome.cost_usd` a published zero.
525/// Non-finite or negative figures are ignored rather than allowed to poison the
526/// total.
527fn record_spend(total: &mut Option<f64>, reported: Option<f64>) {
528    let Some(usd) = reported else { return };
529    if !usd.is_finite() || usd < 0.0 {
530        return;
531    }
532    *total = Some(total.unwrap_or(0.0) + usd);
533}
534
535/// The failing half of a contract evaluation, rendered for a model to act on.
536fn render_check_failures(results: &[CheckResult]) -> String {
537    results
538        .iter()
539        .filter(|r| !r.passed)
540        .map(|r| {
541            format!(
542                "FAILED {} (exit {:?}):\n{}",
543                r.name, r.exit_code, r.output_tail
544            )
545        })
546        .collect::<Vec<_>>()
547        .join("\n\n")
548}
549
550#[cfg(test)]
551mod tests {
552    use std::collections::VecDeque;
553    use std::sync::atomic::AtomicU32;
554    use std::sync::Mutex;
555
556    use super::*;
557    use crate::coder::contract::ContractCheck;
558    use crate::coder::session::CoderEvent;
559    // Checks run through the coder's shell — `sh -lc` on Unix, `cmd /C` on
560    // Windows — so fixtures use the portable builders rather than POSIX
561    // literals. `true` is not a program on Windows (car#760).
562    use crate::coder::test_cmds::PASS;
563
564    fn contract() -> OutcomeContract {
565        OutcomeContract {
566            allow_credentials: false,
567            description: "x".into(),
568            checks: vec![ContractCheck {
569                name: "tests".into(),
570                command: "cargo test".into(),
571                expect_exit_zero: true,
572                output_contains: None,
573                timeout_secs: 300,
574                baseline: false,
575                differential: None,
576            }],
577        }
578    }
579
580    #[test]
581    fn task_carries_intent_contract_and_ground_rules() {
582        let t = build_task("add a CLI flag", &contract(), None);
583        assert!(t.contains("add a CLI flag"));
584        assert!(t.contains("cargo test"));
585        assert!(t.contains("Do NOT git commit"));
586        assert!(!t.contains("FAILING"));
587    }
588
589    #[test]
590    fn repair_task_carries_failure_feedback() {
591        let t = build_task("x", &contract(), Some("FAILED tests (exit Some(1)):\nboom"));
592        assert!(t.contains("previous attempt"));
593        assert!(t.contains("boom"));
594    }
595
596    #[test]
597    fn mcp_endpoint_is_threaded_into_invoke_opts() {
598        let dir = tempfile::tempdir().unwrap();
599        let executor = WorktreeExecutor::new(dir.path());
600        let cfg = ExternalLoopConfig::default();
601        let opts = build_invoke_opts(&executor, &cfg, Some("http://127.0.0.1:9102/mcp"), None);
602        assert_eq!(
603            opts.mcp_endpoint.as_deref(),
604            Some("http://127.0.0.1:9102/mcp")
605        );
606        // The CLI's own built-in tools stay on the default policy.
607        assert!(opts.allowed_tools.is_none());
608    }
609
610    #[test]
611    fn absent_mcp_endpoint_degrades_to_none() {
612        let dir = tempfile::tempdir().unwrap();
613        let executor = WorktreeExecutor::new(dir.path());
614        let cfg = ExternalLoopConfig::default();
615        let opts = build_invoke_opts(&executor, &cfg, None, None);
616        assert!(opts.mcp_endpoint.is_none());
617    }
618
619    // --- Failure classification ------------------------------------------
620
621    /// Pins the mapping. Note this is an array of literals, NOT the exhaustive
622    /// guard — a new upstream variant would compile straight past it. What
623    /// actually forces the decision is the wildcard-free match in
624    /// [`classify_invoke_error`], which fails to compile instead.
625    #[test]
626    fn every_invoke_error_maps_to_its_outcome() {
627        use car_external_agents::InvokeError as E;
628        let cases = [
629            (E::Spawn("no binary".into()), LoopFailure::EngineUnavailable),
630            // Pre-handoff I/O: the agent never received the task, so this is
631            // `Spawn`'s neighbour, not a retryable mid-run fault. It is NOT
632            // `Spawn`'s twin, though: the engine is fine and the environment is
633            // broken, so no other engine is a substitute (car#1534).
634            (E::Setup("stdin closed".into()), LoopFailure::Configuration),
635            (E::Timeout(1800), LoopFailure::Infrastructure),
636            (E::Io("stdout read".into()), LoopFailure::Infrastructure),
637            (E::Cancelled, LoopFailure::Cancelled),
638        ];
639        for (err, want) in cases {
640            assert_eq!(classify_invoke_error(&err), want, "{err}");
641        }
642    }
643
644    /// The car#1534 split, stated as the property the fallback policy reads:
645    /// a broken environment must not wear the one class
646    /// `rpc::fallback_allowed` substitutes another engine on.
647    ///
648    /// `Setup` is also kept off `Infrastructure`, which is this loop's
649    /// non-terminal, retried class — landing there would re-invoke a CLI whose
650    /// environment cannot deliver the prompt, once per transient retry.
651    #[test]
652    fn a_broken_environment_is_not_an_unavailable_engine() {
653        use car_external_agents::InvokeError as E;
654        let setup = classify_invoke_error(&E::Setup("mcp config tempfile: no such file".into()));
655        assert_ne!(
656            setup,
657            LoopFailure::EngineUnavailable,
658            "Setup must not earn the automatic native fallback (car#1534)"
659        );
660        assert_ne!(
661            setup,
662            LoopFailure::Infrastructure,
663            "Setup must not land in the retried, non-terminal class"
664        );
665        assert_eq!(setup, LoopFailure::Configuration);
666        // And the neighbour it used to share a class with keeps its own.
667        assert_eq!(
668            classify_invoke_error(&E::Spawn("no detected external agent 'claude-code'".into())),
669            LoopFailure::EngineUnavailable
670        );
671    }
672
673    /// The distinction the `Setup` split exists for: both are I/O, but one left
674    /// a worktree worth evaluating and the other could not have.
675    #[test]
676    fn setup_and_midrun_io_are_not_the_same_outcome() {
677        use car_external_agents::InvokeError as E;
678        assert_ne!(
679            classify_invoke_error(&E::Setup("stdin closed".into())),
680            classify_invoke_error(&E::Io("stdout read".into())),
681        );
682    }
683
684    // --- Loop behavior, against a scripted CLI ----------------------------
685
686    /// A CLI whose every invocation is scripted, so the loop's budgets, round
687    /// counting and retry policy are observable without a subprocess.
688    struct ScriptedInvoker {
689        script: Mutex<VecDeque<Result<InvokeResult, InvokeError>>>,
690        calls: AtomicU32,
691        /// Every task text handed over, in order — the only place the repair
692        /// feedback is observable from outside the loop.
693        tasks: Mutex<Vec<String>>,
694    }
695
696    impl ScriptedInvoker {
697        fn new(script: Vec<Result<InvokeResult, InvokeError>>) -> Self {
698            Self {
699                script: Mutex::new(script.into()),
700                calls: AtomicU32::new(0),
701                tasks: Mutex::new(Vec::new()),
702            }
703        }
704        fn calls(&self) -> u32 {
705            self.calls.load(Ordering::SeqCst)
706        }
707        fn task(&self, n: usize) -> String {
708            self.tasks.lock().expect("tasks poisoned")[n].clone()
709        }
710    }
711
712    #[async_trait]
713    impl CliInvoker for ScriptedInvoker {
714        async fn invoke(
715            &self,
716            _agent_id: &str,
717            task: &str,
718            _opts: InvokeOptions,
719            _emitter: StreamEventEmitter,
720        ) -> Result<InvokeResult, InvokeError> {
721            self.calls.fetch_add(1, Ordering::SeqCst);
722            self.tasks
723                .lock()
724                .expect("tasks poisoned")
725                .push(task.to_string());
726            self.script
727                .lock()
728                .expect("script poisoned")
729                .pop_front()
730                .unwrap_or_else(|| Err(InvokeError::Spawn("script exhausted".into())))
731        }
732    }
733
734    /// A contract whose single check always fails / always passes, cheaply.
735    fn contract_with(command: &str) -> OutcomeContract {
736        OutcomeContract {
737            allow_credentials: false,
738            description: "x".into(),
739            checks: vec![ContractCheck {
740                name: "gate".into(),
741                command: command.into(),
742                expect_exit_zero: true,
743                output_contains: None,
744                timeout_secs: 30,
745                baseline: false,
746                differential: None,
747            }],
748        }
749    }
750
751    fn clean_run() -> Result<InvokeResult, InvokeError> {
752        Ok(InvokeResult::default())
753    }
754
755    fn errored_run(msg: &str) -> Result<InvokeResult, InvokeError> {
756        Ok(InvokeResult {
757            is_error: true,
758            error: Some(msg.into()),
759            ..Default::default()
760        })
761    }
762
763    async fn run(
764        invoker: &dyn CliInvoker,
765        contract: &OutcomeContract,
766        cfg: &ExternalLoopConfig,
767    ) -> (LoopOutcome, Vec<CoderEvent>) {
768        let dir = tempfile::tempdir().unwrap();
769        let executor = WorktreeExecutor::new(dir.path());
770        let (sink, collected) = EventSink::collecting("t");
771        let sink = Arc::new(sink);
772        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
773        let outcome = run_external_loop(
774            invoker, "codex", "x", contract, &executor, &sink, &cancel, cfg, None, None,
775        )
776        .await;
777        let events = collected.lock().unwrap().clone();
778        (outcome, events)
779    }
780
781    /// **The regression test for the core defect.** A dead transport must not
782    /// be able to fail a session whose worktree already satisfies the contract.
783    /// Before classification moved after evaluation, this returned an error.
784    #[tokio::test]
785    async fn a_timeout_over_green_checks_still_passes() {
786        let invoker = ScriptedInvoker::new(vec![Err(InvokeError::Timeout(1800))]);
787        let (outcome, _) = run(
788            &invoker,
789            &contract_with(PASS),
790            &ExternalLoopConfig::default(),
791        )
792        .await;
793        assert!(outcome.passed, "the contract, not the transport, decides");
794        assert_eq!(outcome.failure, None);
795        assert_eq!(outcome.iterations, 1);
796    }
797
798    /// A transient retry buys an invocation without spending a hypothesis.
799    /// Budgets: 1 + repair_invokes(1) hypotheses, transient_retries(1) retries
800    /// => exactly 3 invocations, and 3 contract-evaluation rounds.
801    #[tokio::test]
802    async fn a_transient_retry_does_not_spend_a_hypothesis() {
803        let invoker = ScriptedInvoker::new(vec![
804            Err(InvokeError::Timeout(1)),
805            Err(InvokeError::Timeout(1)),
806            Err(InvokeError::Timeout(1)),
807            Err(InvokeError::Timeout(1)),
808        ]);
809        let (outcome, events) = run(
810            &invoker,
811            &contract_with("exit 1"),
812            &ExternalLoopConfig::default(),
813        )
814        .await;
815        assert_eq!(invoker.calls(), 4, "3 hypotheses + 1 transient retry");
816        assert_eq!(outcome.iterations, 4, "every invocation evaluated");
817        // The retry gets its own event, and does NOT re-fire the hypothesis
818        // banner — otherwise `iteration 1/2` would print twice for one attempt.
819        let started = events
820            .iter()
821            .filter(|e| matches!(e.kind, CoderEventKind::IterationStarted { .. }))
822            .count();
823        let retried = events
824            .iter()
825            .filter(|e| matches!(e.kind, CoderEventKind::InvocationRetried { .. }))
826            .count();
827        assert_eq!(started, 3, "one banner per hypothesis");
828        assert_eq!(retried, 1);
829    }
830
831    /// Exhausting the transient budget must still LOOK infrastructural, or
832    /// `car-cli`'s A/B scores a dead transport as a genuine task loss and
833    /// biases the arm's pass rate.
834    #[tokio::test]
835    async fn exhausted_infrastructure_keeps_the_scraped_error_prefix() {
836        let invoker = ScriptedInvoker::new(vec![
837            Err(InvokeError::Timeout(1)),
838            Err(InvokeError::Timeout(1)),
839            Err(InvokeError::Timeout(1)),
840            Err(InvokeError::Timeout(1)),
841        ]);
842        let (outcome, _) = run(
843            &invoker,
844            &contract_with("exit 1"),
845            &ExternalLoopConfig::default(),
846        )
847        .await;
848        assert_eq!(outcome.failure, Some(LoopFailure::Infrastructure));
849        let err = outcome
850            .error
851            .expect("an exhausted infra failure must still surface as infra");
852        assert!(
853            err.starts_with("external agent '"),
854            "car-cli INFRA_MARKERS depends on this prefix: {err}"
855        );
856    }
857
858    /// Setup failures never reach the contract: nothing ran, so there is
859    /// nothing to evaluate, and the session ends there.
860    ///
861    /// Since car#1534 the class is `Configuration`, not `EngineUnavailable`:
862    /// the engine was fine and its environment was not, so no other engine is a
863    /// substitute and `rpc` must not start one.
864    #[tokio::test]
865    async fn a_setup_failure_does_not_retry_or_evaluate() {
866        let invoker = ScriptedInvoker::new(vec![Err(InvokeError::Setup(
867            "mcp config tempfile: No such file or directory (os error 2)".into(),
868        ))]);
869        let (outcome, _) = run(
870            &invoker,
871            &contract_with("exit 1"),
872            &ExternalLoopConfig::default(),
873        )
874        .await;
875        assert_eq!(invoker.calls(), 1, "no retry: nothing ran");
876        assert_eq!(outcome.iterations, 0, "the contract was never consulted");
877        assert_eq!(outcome.failure, Some(LoopFailure::Configuration));
878        assert_ne!(
879            outcome.failure,
880            Some(LoopFailure::EngineUnavailable),
881            "a broken environment must not earn the automatic native fallback"
882        );
883        let err = outcome.error.expect("setup failure must surface");
884        assert!(err.starts_with("external agent '"), "{err}");
885        // The typed cause names the underlying error, which is the whole ask of
886        // car#1534's "ends the session with a typed cause naming the error".
887        assert!(err.contains("mcp config tempfile"), "{err}");
888    }
889
890    /// **GUARD (car#1534).** `car-cli`'s A/B keeps a never-attempted cell out
891    /// of the scored denominator two ways: the TYPED `failure_kind:` line
892    /// (`coder_ab::kind_is_infra`, which lists `infrastructure` and
893    /// `auth_required`) and, failing that, a prose scan for
894    /// `coder_ab::INFRA_MARKERS`.
895    ///
896    /// A `Setup` failure now renders as `failure_kind: configuration`, so the
897    /// typed path no longer covers it — deliberately, because widening
898    /// `kind_is_infra` is A/B scoring policy and out of this issue's scope. The
899    /// prose marker `"external agent '"` is therefore the ONLY thing still
900    /// excluding a broken environment from the scored denominator, and this
901    /// pins it: an out-of-process scrape must keep matching this text.
902    ///
903    /// The marker list lives in another crate, so it is restated here rather
904    /// than imported. That is the point — this asserts the wire shape a
905    /// different process reads, not a shared constant.
906    #[tokio::test]
907    async fn a_setup_failures_text_still_hits_the_ab_prose_exclusion() {
908        const AB_INFRA_MARKER: &str = "external agent '";
909        let invoker = ScriptedInvoker::new(vec![Err(InvokeError::Setup(
910            "mcp config tempfile: No such file or directory (os error 2)".into(),
911        ))]);
912        let (outcome, _) = run(
913            &invoker,
914            &contract_with("exit 1"),
915            &ExternalLoopConfig::default(),
916        )
917        .await;
918        let err = outcome.error.expect("setup failure must surface");
919        assert!(
920            err.contains(AB_INFRA_MARKER),
921            "coder_ab::INFRA_MARKERS must still exclude this cell: {err}"
922        );
923        // `car code` prints the terminal error as `failed: <err>`; that is the
924        // line the A/B scrapes. Pin the whole shape, not just the substring.
925        assert!(
926            format!("failed: {err}").contains("failed: external agent 'codex' failed: "),
927            "{err}"
928        );
929    }
930
931    /// A CLI that ran and reported its own error is `Execution`; one that ran
932    /// clean and simply got it wrong is `Verification`. Same action today, but
933    /// the repair feedback differs — only `Execution` has the CLI's own error
934    /// to pass back.
935    #[tokio::test]
936    async fn execution_and_verification_are_distinguished() {
937        let cfg = ExternalLoopConfig {
938            repair_invokes: 0,
939            ..Default::default()
940        };
941        let (errored, _) = run(
942            &ScriptedInvoker::new(vec![errored_run("tool denied")]),
943            &contract_with("exit 1"),
944            &cfg,
945        )
946        .await;
947        assert_eq!(errored.failure, Some(LoopFailure::Execution));
948        assert!(errored.error.is_none(), "the CLI ran; this is a task loss");
949
950        let (clean, _) = run(
951            &ScriptedInvoker::new(vec![clean_run()]),
952            &contract_with("exit 1"),
953            &cfg,
954        )
955        .await;
956        assert_eq!(clean.failure, Some(LoopFailure::Verification));
957    }
958
959    /// A repair that lands the IDENTICAL failure is told so, rather than handed
960    /// the same feedback text a second time. Before this, every repair round
961    /// re-sent the failing checks verbatim with no signal that the previous
962    /// attempt had changed nothing.
963    #[tokio::test]
964    async fn a_repeated_failure_escalates_the_repair_feedback() {
965        // Needs three hypotheses, not the default two: round 1 establishes the
966        // signature, round 2 is the first that can REPEAT it, and only round 3
967        // can be told. See `repair_invokes` on why the default cannot escalate.
968        let cfg = ExternalLoopConfig {
969            repair_invokes: 2,
970            ..Default::default()
971        };
972        let invoker = ScriptedInvoker::new(vec![clean_run(), clean_run(), clean_run()]);
973        let (outcome, _) = run(&invoker, &contract_with("exit 1"), &cfg).await;
974        assert_eq!(invoker.calls(), 3);
975        assert_eq!(outcome.failure, Some(LoopFailure::Verification));
976
977        // Rounds 1 and 2: nothing has repeated yet from the model's side.
978        assert!(!invoker.task(0).contains("failed the same way"));
979        assert!(!invoker.task(1).contains("failed the same way"));
980        // Round 3: round 2 reproduced round 1's signature exactly. Say so.
981        let repair = invoker.task(2);
982        assert!(repair.contains("failed the same way 2 times"), "{repair}");
983        assert!(repair.contains("DIFFERENT hypothesis"));
984    }
985
986    /// A transport failure must NOT escalate: the attempt was cut short before
987    /// it could have changed the outcome, so a repeated check result says
988    /// nothing about the hypothesis.
989    #[tokio::test]
990    async fn a_cut_short_attempt_does_not_escalate() {
991        let invoker = ScriptedInvoker::new(vec![
992            Err(InvokeError::Timeout(1)),
993            Err(InvokeError::Timeout(1)),
994            Err(InvokeError::Timeout(1)),
995            Err(InvokeError::Timeout(1)),
996        ]);
997        let (_, _) = run(
998            &invoker,
999            &contract_with("exit 1"),
1000            &ExternalLoopConfig::default(),
1001        )
1002        .await;
1003        for n in 0..invoker.calls() as usize {
1004            assert!(
1005                !invoker.task(n).contains("failed the same way"),
1006                "a timeout is not evidence about the hypothesis (task {n})"
1007            );
1008        }
1009    }
1010
1011    /// An exhausted session budget denies the FIRST admission — before any CLI
1012    /// is invoked — and is its own terminal, not a task loss. Conflating it with
1013    /// `Verification` would teach the recurrence machinery that an approach
1014    /// failed when it was merely cut off.
1015    #[tokio::test]
1016    async fn an_exhausted_budget_denies_admission_before_invoking() {
1017        let cfg = ExternalLoopConfig {
1018            // A deadline that is already spent.
1019            deadline: std::sync::Arc::new(SessionDeadline::new(Some(0))),
1020            ..Default::default()
1021        };
1022        let invoker = ScriptedInvoker::new(vec![clean_run()]);
1023        let (outcome, events) = run(&invoker, &contract_with("exit 1"), &cfg).await;
1024        assert_eq!(invoker.calls(), 0, "the budget gates before any work");
1025        assert_eq!(outcome.failure, Some(LoopFailure::BudgetExhausted));
1026        assert_ne!(outcome.failure, Some(LoopFailure::Verification));
1027        assert!(outcome
1028            .error
1029            .expect("the reason must surface")
1030            .contains("session budget exhausted"));
1031        assert!(events
1032            .iter()
1033            .any(|e| matches!(e.kind, CoderEventKind::BudgetExhausted { .. })));
1034    }
1035
1036    /// **The clamp.** Admission grants a whole round, so without this a
1037    /// hypothesis admitted just under the ceiling could run its full 1800s past
1038    /// it — a ceiling exceeded by 50% is not a ceiling. The invocation's own
1039    /// timeout is reduced to what the session has left.
1040    #[test]
1041    fn an_invocation_timeout_is_clamped_to_the_session_remainder() {
1042        let dir = tempfile::tempdir().unwrap();
1043        let executor = WorktreeExecutor::new(dir.path());
1044
1045        // 10s left on the session, 1800s asked for by the invocation.
1046        let tight = ExternalLoopConfig {
1047            timeout_secs: Some(1800),
1048            deadline: std::sync::Arc::new(SessionDeadline::new(Some(10))),
1049            ..Default::default()
1050        };
1051        let opts = build_invoke_opts(&executor, &tight, None, None);
1052        assert_eq!(
1053            opts.timeout_secs,
1054            Some(10),
1055            "the round must not outlive the session"
1056        );
1057
1058        // Plenty of session left: the invocation keeps its own, smaller bound.
1059        let roomy = ExternalLoopConfig {
1060            timeout_secs: Some(60),
1061            ..Default::default()
1062        };
1063        assert_eq!(
1064            build_invoke_opts(&executor, &roomy, None, None).timeout_secs,
1065            Some(60)
1066        );
1067
1068        // No session ceiling: the invocation's own bound stands unchanged.
1069        let unbounded = ExternalLoopConfig {
1070            timeout_secs: Some(60),
1071            deadline: SessionDeadline::unlimited(),
1072            ..Default::default()
1073        };
1074        assert_eq!(
1075            build_invoke_opts(&executor, &unbounded, None, None).timeout_secs,
1076            Some(60)
1077        );
1078    }
1079
1080    /// The whole point of the `Arc`: a second rung of the fallback ladder gets
1081    /// the SAME clock, not a fresh one. Before this, `external -> native` and
1082    /// `foreman -> native` each restarted the ceiling.
1083    #[test]
1084    fn a_second_rung_shares_the_first_rungs_clock() {
1085        let first = ExternalLoopConfig {
1086            deadline: std::sync::Arc::new(SessionDeadline::new(Some(0))),
1087            ..Default::default()
1088        };
1089        // How `rpc` builds the fallback rung: clone the handle, not the value.
1090        let second = ExternalLoopConfig {
1091            deadline: std::sync::Arc::clone(&first.deadline),
1092            ..Default::default()
1093        };
1094        assert!(
1095            std::sync::Arc::ptr_eq(&first.deadline, &second.deadline),
1096            "the fallback must not buy the session another full ceiling"
1097        );
1098        assert!(
1099            second.deadline.admit().is_some(),
1100            "an already-spent session must stay spent across the ladder"
1101        );
1102    }
1103
1104    /// The default budget must not interfere with an ordinary session.
1105    #[tokio::test]
1106    async fn the_default_budget_does_not_gate_a_normal_run() {
1107        let invoker = ScriptedInvoker::new(vec![clean_run()]);
1108        let (outcome, _) = run(
1109            &invoker,
1110            &contract_with(PASS),
1111            &ExternalLoopConfig::default(),
1112        )
1113        .await;
1114        assert!(outcome.passed);
1115        assert_eq!(invoker.calls(), 1);
1116    }
1117
1118    /// A clean run that loses on the checks is a task loss, not an infra one —
1119    /// it must NOT carry the scraped infra prefix.
1120    #[tokio::test]
1121    async fn a_verification_loss_carries_no_infra_marker() {
1122        let cfg = ExternalLoopConfig {
1123            repair_invokes: 0,
1124            ..Default::default()
1125        };
1126        let (outcome, _) = run(
1127            &ScriptedInvoker::new(vec![clean_run()]),
1128            &contract_with("exit 1"),
1129            &cfg,
1130        )
1131        .await;
1132        assert_eq!(outcome.failure, Some(LoopFailure::Verification));
1133        assert!(
1134            outcome.error.is_none(),
1135            "a genuine task loss must stay in the scored denominator"
1136        );
1137    }
1138
1139    /// The one test that exercises the REAL classification path end-to-end.
1140    /// Every other loop test scripts the invoker, so without this nothing
1141    /// verifies that a genuinely absent CLI still produces `Spawn` ->
1142    /// `EngineUnavailable` -> the scraped prefix. That is the standing cost of
1143    /// introducing a seam, and it is worth paying once.
1144    #[tokio::test]
1145    async fn a_missing_cli_is_engine_unavailable_through_the_live_invoker() {
1146        let dir = tempfile::tempdir().unwrap();
1147        let executor = WorktreeExecutor::new(dir.path());
1148        let sink = Arc::new(EventSink::test_sink());
1149        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
1150        let outcome = run_external_loop(
1151            &LiveInvoker,
1152            "no-such-cli",
1153            "x",
1154            &contract(),
1155            &executor,
1156            &sink,
1157            &cancel,
1158            &ExternalLoopConfig::default(),
1159            None,
1160            None,
1161        )
1162        .await;
1163        assert!(!outcome.passed);
1164        assert_eq!(outcome.failure, Some(LoopFailure::EngineUnavailable));
1165        let err = outcome.error.expect("spawn failure must surface");
1166        assert!(err.starts_with("external agent '"), "{err}");
1167        assert!(err.contains("no-such-cli"), "{err}");
1168    }
1169
1170    /// Cancellation must stay distinguishable from "the engine could not run",
1171    /// because `rpc` starts a native loop on the latter and moves to a
1172    /// different terminal state. Conflating them starts work the user stopped.
1173    #[tokio::test]
1174    async fn cancellation_is_not_engine_unavailable() {
1175        let dir = tempfile::tempdir().unwrap();
1176        let executor = WorktreeExecutor::new(dir.path());
1177        let sink = Arc::new(EventSink::test_sink());
1178        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(true));
1179        let invoker = ScriptedInvoker::new(vec![]);
1180        let outcome = run_external_loop(
1181            &invoker,
1182            "claude-code",
1183            "x",
1184            &contract(),
1185            &executor,
1186            &sink,
1187            &cancel,
1188            &ExternalLoopConfig::default(),
1189            None,
1190            None,
1191        )
1192        .await;
1193        assert_eq!(invoker.calls(), 0, "pre-cancelled must not invoke");
1194        assert_eq!(outcome.failure, Some(LoopFailure::Cancelled));
1195        assert_ne!(outcome.failure, Some(LoopFailure::EngineUnavailable));
1196        // The bare spelling `rpc` and the A/B both still read.
1197        assert_eq!(outcome.error.as_deref(), Some("cancelled"));
1198    }
1199
1200    #[test]
1201    fn rendered_feedback_carries_only_failing_checks() {
1202        let results = vec![
1203            CheckResult {
1204                credentials_allowed: false,
1205                name: "build".into(),
1206                passed: true,
1207                exit_code: Some(0),
1208                output_tail: "ok".into(),
1209                duration_ms: 1,
1210                timed_out: false,
1211                deadline_clamped: false,
1212            },
1213            CheckResult {
1214                credentials_allowed: false,
1215                name: "tests".into(),
1216                passed: false,
1217                exit_code: Some(1),
1218                output_tail: "assertion failed".into(),
1219                duration_ms: 2,
1220                timed_out: false,
1221                deadline_clamped: false,
1222            },
1223        ];
1224        let rendered = render_check_failures(&results);
1225        assert!(rendered.contains("FAILED tests"));
1226        assert!(rendered.contains("assertion failed"));
1227        assert!(!rendered.contains("build"), "passing checks are noise");
1228    }
1229}