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}