Skip to main content

car_server_core/coder/
native_loop.rs

1//! The native coding loop: plan → edit → verify → repair, on CAR inference.
2//!
3//! Shape mirrors `car-bench`'s `InferenceAgentRunner` (multi-turn tool-use
4//! conversation) wrapped in `car-builder`'s repair-loop philosophy: each
5//! iteration appends the previous iteration's failing check output to a
6//! conversation that PERSISTS across repair rounds (F2, audit 2026-07-06 —
7//! see the note above `run_native_loop`), bounded each turn by
8//! [`compact_history_to_window`]. The loop only exits green when
9//! [`evaluate_contract`](super::contract::evaluate_contract) — not the
10//! model — says so.
11
12use std::collections::HashMap;
13use std::sync::atomic::Ordering;
14use std::sync::Arc;
15
16use async_trait::async_trait;
17use car_engine::{builtin_tool_labels, tool_output_is_external, ToolExecutor};
18use car_inference::tasks::generate::{Message, Provenance};
19use car_inference::{
20    GenerateParams, GenerateRequest, InferenceEngine, InferenceError, InferenceResult,
21    InferenceRetryProgress,
22};
23use serde_json::Value;
24
25use super::budget::SessionDeadline;
26use super::contract::{evaluate_contract_with_baselines, CheckResult, OutcomeContract};
27use super::session::{CancelFlag, CoderEventKind, EventSink, NoChangeKind, NoChangeNomination};
28use super::shell_tool::WorktreeExecutor;
29use super::skill_memory::{FailureSignature, RepairMemory};
30use crate::assistant::agent_loop::{compact_history_to_window, history_budget};
31
32/// The model seam: one turn of generation. Implemented by
33/// [`InferenceEngine`] for production; test harnesses script it (the same
34/// injected-generation philosophy as `car-builder`).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum InferenceFailureKind {
37    LocalResourceBlocked,
38    CredentialUnavailable,
39    ProviderAccount,
40    ProviderKeyMissing,
41    GatewayUnconfigured,
42    NoBackend,
43    NoEligibleModel,
44    /// The credential works; the account behind it has no workspace. Typed
45    /// apart from `CredentialUnavailable` deliberately — the remedy is on the
46    /// web, not `car auth login`, so the terminal screen must not say "sign
47    /// in" and the loop must not enter the sign-in wait.
48    WorkspaceRequired,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum TurnGenerationError {
53    /// Strict routing removed every candidate before a provider was invoked.
54    NoEligibleModel { excluded_models: String },
55    /// A retry cannot repair this inference failure. `recovery` is the
56    /// operator-facing text from the typed inference error.
57    NonRetryableInference {
58        kind: InferenceFailureKind,
59        recovery: String,
60    },
61    /// Every other inference failure retains its existing operator-facing text.
62    Other(String),
63}
64
65impl TurnGenerationError {
66    /// The terminal cause an unattended coder must surface instead of retrying.
67    pub fn terminal_inference(&self) -> Option<(InferenceFailureKind, String)> {
68        match self {
69            // Chat renders this failure through `InferenceError`'s own Display.
70            // The recovery text an operator reads must be the SAME sentence in
71            // the agent builder, so rebuild it from the inference error rather
72            // than from this enum's coder-flavoured Display (which the heal
73            // runner's own message still relies on — see `run_native_loop`).
74            Self::NoEligibleModel { excluded_models } => Some((
75                InferenceFailureKind::NoEligibleModel,
76                InferenceError::NoEligibleModel {
77                    excluded_models: excluded_models.clone(),
78                }
79                .to_string(),
80            )),
81            Self::NonRetryableInference { kind, recovery } => Some((*kind, recovery.clone())),
82            Self::Other(_) => None,
83        }
84    }
85}
86
87impl std::fmt::Display for TurnGenerationError {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            Self::NoEligibleModel { excluded_models } => write!(
91                f,
92                "no eligible model remains after strict exclusions: {}",
93                excluded_models
94            ),
95            Self::NonRetryableInference { recovery, .. } | Self::Other(recovery) => {
96                f.write_str(recovery)
97            }
98        }
99    }
100}
101
102impl From<InferenceError> for TurnGenerationError {
103    fn from(error: InferenceError) -> Self {
104        match error {
105            InferenceError::NoEligibleModel { excluded_models } => {
106                Self::NoEligibleModel { excluded_models }
107            }
108            InferenceError::LocalResourceBlocked { recovery, .. } => Self::NonRetryableInference {
109                kind: InferenceFailureKind::LocalResourceBlocked,
110                recovery,
111            },
112            error @ InferenceError::CredentialUnavailable {
113                reason: car_inference::CredentialFailure::RaceRetryable,
114                ..
115            } => Self::Other(error.to_string()),
116            error @ InferenceError::CredentialUnavailable { .. } => Self::NonRetryableInference {
117                kind: InferenceFailureKind::CredentialUnavailable,
118                recovery: error.to_string(),
119            },
120            error @ InferenceError::ProviderAccount { .. } => Self::NonRetryableInference {
121                kind: InferenceFailureKind::ProviderAccount,
122                recovery: error.to_string(),
123            },
124            // A provider key that resolved to nothing is configuration, and a
125            // key that was never set does not appear part-way through one
126            // unattended build: retrying burns the remaining attempts and still
127            // ends on the same sentence. Typed apart from `ProviderAccount` (a
128            // key the provider REJECTED) and from `CredentialUnavailable` (the
129            // Parslee sign-in path, which this error is deliberately NOT — see
130            // `missing_provider_keys_do_not_enter_the_parslee_sign_in_wait`),
131            // so the terminal screen names the remedy that actually applies
132            // (Parslee-ai/car#1544).
133            error @ InferenceError::ProviderKeyMissing { .. } => Self::NonRetryableInference {
134                kind: InferenceFailureKind::ProviderKeyMissing,
135                recovery: error.to_string(),
136            },
137            error @ InferenceError::GatewayUnconfigured { .. } => Self::NonRetryableInference {
138                kind: InferenceFailureKind::GatewayUnconfigured,
139                recovery: error.to_string(),
140            },
141            // The account has no workspace yet. Retrying cannot create one, and
142            // this is NOT the Parslee sign-in path: the credential resolved and
143            // worked. `is_auth_failure` classifies on car-inference's auth
144            // markers and this error's Display matches none of them by design,
145            // so the loop ends terminally here instead of parking on a sign-in
146            // that would lead straight back to the same wall.
147            error @ InferenceError::WorkspaceRequired { .. } => Self::NonRetryableInference {
148                kind: InferenceFailureKind::WorkspaceRequired,
149                recovery: error.to_string(),
150            },
151            error @ InferenceError::ModelNotFound(_) => Self::NonRetryableInference {
152                kind: InferenceFailureKind::NoBackend,
153                recovery: error.to_string(),
154            },
155            InferenceError::InferenceFailed(message)
156                if is_no_backend_inference_failure(&message) =>
157            {
158                Self::NonRetryableInference {
159                    kind: InferenceFailureKind::NoBackend,
160                    recovery: format!("inference failed: {message}"),
161                }
162            }
163            // Everything below stays RETRYABLE in an unattended build. The match
164            // is deliberately exhaustive: a new `InferenceError` variant must
165            // fail to compile here so nobody can add a terminal configuration
166            // failure and have it silently burn three attempts.
167            //
168            // A download can succeed on the next attempt — the network, the
169            // mirror or the free disk may all recover inside one build.
170            error @ InferenceError::DownloadFailed(_) => Self::Other(error.to_string()),
171            // Generic generation failure without the no-backend marker: a decode
172            // or provider hiccup the next attempt can clear.
173            error @ InferenceError::InferenceFailed(_) => Self::Other(error.to_string()),
174            // Optimistic-concurrency rejection against a catalog snapshot; the
175            // caller re-reads the snapshot, so the retry can bind cleanly.
176            error @ InferenceError::CatalogPreconditionMismatch { .. } => {
177                Self::Other(error.to_string())
178            }
179            // A confirmed worker kill; the registry above decides whether the
180            // outward terminal is cancel or deadline, and the builder must not
181            // pre-empt that with a configuration screen.
182            error @ InferenceError::ControlledTermination => Self::Other(error.to_string()),
183            // Install/remove bookkeeping, transient while another management
184            // operation holds the model.
185            error @ InferenceError::ModelManagement(_) => Self::Other(error.to_string()),
186            // Explicitly the retryable remote class (5xx / 429 / timeout).
187            error @ InferenceError::Transient { .. } => Self::Other(error.to_string()),
188            // The caller's own infer deadline; the build's budget layer owns that
189            // terminal, and a shorter turn can still fit inside the run.
190            error @ InferenceError::DeadlineExceeded { .. } => Self::Other(error.to_string()),
191            // This backend has not wired the mode; the next routed candidate may
192            // have, so the retry is not pointless. (Follow-up candidate for
193            // terminal classification — see the report.)
194            error @ InferenceError::UnsupportedMode { .. } => Self::Other(error.to_string()),
195            // A refusal is about this prompt, not the machine's setup: repairing
196            // the prompt on the next attempt is exactly the remedy.
197            error @ InferenceError::ContentRefused { .. } => Self::Other(error.to_string()),
198            // Tokenizer fault on one request's text, not a setup gap.
199            error @ InferenceError::TokenizationError(_) => Self::Other(error.to_string()),
200            // Accelerator fault; a later attempt may land on a healthy device.
201            error @ InferenceError::DeviceError(_) => Self::Other(error.to_string()),
202            // Filesystem/socket I/O, the classic retryable mechanic.
203            error @ InferenceError::Io(_) => Self::Other(error.to_string()),
204        }
205    }
206}
207
208/// Legacy no-backend exhaustion is still carried by `InferenceFailed`; match the
209/// marker car-inference itself builds its recovery hint from, so producer and
210/// consumer cannot drift. `contains`, not `starts_with`: car-inference's
211/// `apply_route_failure_context` prepends a credential summary
212/// (`"{summary}; {hint}"`) whenever a route also failed to produce a credential,
213/// which is exactly the fresh-install case this classifier exists for. Generic
214/// `InferenceFailed` remains retryable rather than turning prose into policy.
215fn is_no_backend_inference_failure(message: &str) -> bool {
216    message.contains(car_inference::NO_BACKEND_RECOVERY_MARKER)
217}
218
219/// The typed failure of one ASSISTANT turn's generation.
220///
221/// A separate type from [`TurnGenerationError`] on purpose. The coder's enum
222/// drives an unattended build's retry-versus-terminal policy and is pinned by
223/// four fixtures; the assistant needs something else entirely — the small set of
224/// failures a chat turn can END on with a remedy a person can act on. Widening
225/// the coder's enum to serve both would have changed coder behaviour and its
226/// fixtures to ship a chat feature, which is the wrong trade in both
227/// directions.
228///
229/// Everything outside those cases keeps the string seam the assistant has
230/// always had, so this enum stays deliberately small and its `From` stays
231/// deliberately non-exhaustive.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum AssistantGenerateError {
234    /// No usable credential for this provider, and why — branch on `reason`,
235    /// never on the text.
236    CredentialUnavailable {
237        provider: String,
238        reason: car_inference::CredentialFailure,
239        message: String,
240    },
241    /// The credential works; the account behind it has no workspace yet.
242    WorkspaceRequired { provider: String, message: String },
243    /// A retryable remote failure whose provider-level retry budget was
244    /// exhausted. Kept typed so a long assistant run does not flatten the
245    /// reason into prose at the terminal receipt boundary.
246    Transient {
247        status: Option<u16>,
248        message: String,
249    },
250    /// Every other inference failure, with its operator-facing text unchanged.
251    Other(String),
252}
253
254impl std::fmt::Display for AssistantGenerateError {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        match self {
257            Self::CredentialUnavailable { message, .. }
258            | Self::WorkspaceRequired { message, .. }
259            | Self::Transient { message, .. }
260            | Self::Other(message) => f.write_str(message),
261        }
262    }
263}
264
265impl From<InferenceError> for AssistantGenerateError {
266    fn from(error: InferenceError) -> Self {
267        let message = error.to_string();
268        match error {
269            InferenceError::CredentialUnavailable {
270                provider, reason, ..
271            } => Self::CredentialUnavailable {
272                provider,
273                reason,
274                message,
275            },
276            InferenceError::WorkspaceRequired { provider, .. } => {
277                Self::WorkspaceRequired { provider, message }
278            }
279            InferenceError::Transient { status, .. } => Self::Transient { status, message },
280            // Wildcard by design, unlike the coder's `From`. That match exists
281            // to force a decision about retry budget on every new variant; this
282            // one only asks "can a chat turn end on it with a remedy", and the
283            // honest default for anything new is no.
284            _ => Self::Other(message),
285        }
286    }
287}
288
289#[async_trait]
290pub trait TurnGenerator: Send + Sync {
291    async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String>;
292
293    /// Coder-specific generation keeps routing exhaustion typed so an
294    /// unattended caller can classify configuration failures without parsing
295    /// error prose. Other consumers retain the original string seam.
296    async fn generate_coder(
297        &self,
298        req: GenerateRequest,
299    ) -> Result<InferenceResult, TurnGenerationError> {
300        self.generate(req).await.map_err(TurnGenerationError::Other)
301    }
302
303    async fn generate_coder_observed(
304        &self,
305        req: GenerateRequest,
306        _retry_observer: &mut (dyn FnMut(InferenceRetryProgress) + Send),
307    ) -> Result<InferenceResult, TurnGenerationError> {
308        self.generate_coder(req).await
309    }
310
311    /// Assistant-specific generation: keeps the failures a chat turn can END on
312    /// typed, so the loop can answer with a remedy instead of
313    /// `inference failed: <prose>`.
314    ///
315    /// The default body delegates to [`TurnGenerator::generate`] and buckets
316    /// the string as `Other`, so every existing implementor — production,
317    /// harness, and every `dyn TurnGenerator` site — inherits it unchanged and
318    /// no test double has to be rewritten to keep compiling.
319    async fn generate_assistant(
320        &self,
321        req: GenerateRequest,
322    ) -> Result<InferenceResult, AssistantGenerateError> {
323        self.generate(req)
324            .await
325            .map_err(AssistantGenerateError::Other)
326    }
327
328    /// Assistant generation with observable provider retries. Implementors
329    /// that do not retry inherit the ordinary one-call path.
330    async fn generate_assistant_observed(
331        &self,
332        req: GenerateRequest,
333        _retry_observer: &mut (dyn FnMut(InferenceRetryProgress) + Send),
334    ) -> Result<InferenceResult, AssistantGenerateError> {
335        self.generate_assistant(req).await
336    }
337
338    /// The context window (in tokens) of the model this generator drives, or
339    /// `0` when unknown. Multi-turn drivers use it to bound their running
340    /// message history before it overflows the window. Defaults to `0` so
341    /// test doubles need not implement it (the loop then skips compaction).
342    fn context_window(&self, _model: &str) -> usize {
343        0
344    }
345}
346
347#[async_trait]
348impl TurnGenerator for InferenceEngine {
349    async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
350        self.generate_tracked(req).await.map_err(|e| e.to_string())
351    }
352
353    async fn generate_coder(
354        &self,
355        req: GenerateRequest,
356    ) -> Result<InferenceResult, TurnGenerationError> {
357        self.generate_tracked(req)
358            .await
359            .map_err(TurnGenerationError::from)
360    }
361
362    async fn generate_assistant(
363        &self,
364        req: GenerateRequest,
365    ) -> Result<InferenceResult, AssistantGenerateError> {
366        self.generate_tracked(req)
367            .await
368            .map_err(AssistantGenerateError::from)
369    }
370
371    async fn generate_coder_observed(
372        &self,
373        req: GenerateRequest,
374        retry_observer: &mut (dyn FnMut(InferenceRetryProgress) + Send),
375    ) -> Result<InferenceResult, TurnGenerationError> {
376        self.generate_tracked_with_retry_observer(req, retry_observer)
377            .await
378            .map_err(TurnGenerationError::from)
379    }
380
381    async fn generate_assistant_observed(
382        &self,
383        req: GenerateRequest,
384        retry_observer: &mut (dyn FnMut(InferenceRetryProgress) + Send),
385    ) -> Result<InferenceResult, AssistantGenerateError> {
386        self.generate_tracked_with_retry_observer(req, retry_observer)
387            .await
388            .map_err(AssistantGenerateError::from)
389    }
390
391    fn context_window(&self, model: &str) -> usize {
392        self.model_context_window(model)
393    }
394}
395
396#[derive(Debug)]
397enum NativeTurnError {
398    TimedOut,
399    Generation(TurnGenerationError),
400}
401
402async fn generate_native_turn(
403    inference: &dyn TurnGenerator,
404    req: GenerateRequest,
405    deadline: &SessionDeadline,
406    sink: &EventSink,
407) -> Result<InferenceResult, NativeTurnError> {
408    let started = tokio::time::Instant::now();
409    let mut on_retry = |retry: InferenceRetryProgress| {
410        sink.emit(CoderEventKind::InferenceRetry {
411            model: retry.model,
412            attempt: retry.attempt,
413            reason: retry.reason.into(),
414            backoff_ms: retry.backoff_ms,
415        });
416    };
417    let generation = inference.generate_coder_observed(req, &mut on_retry);
418    let bounded = async {
419        match deadline.remaining_secs() {
420            Some(left) => tokio::time::timeout(std::time::Duration::from_secs(left), generation)
421                .await
422                .map_err(|_| NativeTurnError::TimedOut)?
423                .map_err(NativeTurnError::Generation),
424            None => generation.await.map_err(NativeTurnError::Generation),
425        }
426    };
427    tokio::pin!(bounded);
428    let mut progress = tokio::time::interval_at(
429        started + std::time::Duration::from_secs(15),
430        std::time::Duration::from_secs(15),
431    );
432    progress.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
433    loop {
434        tokio::select! {
435            biased;
436            result = &mut bounded => return result,
437            _ = progress.tick() => {
438                sink.emit(CoderEventKind::InferenceWaiting { elapsed_secs: started.elapsed().as_secs() });
439            }
440        }
441    }
442}
443
444/// The mid-session user-input seam: the loop hands a prompt to the host, which
445/// surfaces it (emit `UserInputRequested`), blocks for the user's reply (while
446/// respecting cancellation and a bound), and returns the text — or an `Err`
447/// describing why no answer is coming (timeout, cancellation, no listener). An
448/// `Err` is fed back to the model as a tool error so it can proceed without the
449/// answer rather than the loop wedging.
450///
451/// Optional by design: when no asker is wired (most tests, the foreman/external
452/// fallbacks), the `ask_user` tool is simply not offered to the model.
453#[async_trait]
454pub trait AskUser: Send + Sync {
455    async fn ask(&self, prompt: &str) -> Result<String, String>;
456}
457
458/// Can the runtime reach a usable credential right now?
459///
460/// Exists so a session blocked on **sign-in** can wait for the human instead of
461/// dying. Before this, an expired token and a dead datacenter both surfaced as
462/// `LoopFailure::Infrastructure` and ended the run — discarding a worktree of
463/// real edits because a token lapsed while the operator sat at the machine. The
464/// two are not the same condition: one is unrecoverable in-session, the other
465/// is recoverable in seconds by asking.
466///
467/// Optional by design, mirroring [`AskUser`]: with no gate wired the loop keeps
468/// its previous behaviour exactly, so tests and the foreman/external rungs are
469/// unaffected.
470#[async_trait]
471pub trait AuthGate: Send + Sync + std::fmt::Debug {
472    /// Whether a credential the backbone will accept is available.
473    async fn is_authenticated(&self) -> bool;
474}
475
476/// Does this inference error mean "the human must sign in", as opposed to "the
477/// backbone is unreachable"?
478///
479/// Matched on the message because the typed distinction is lost by the time the
480/// error reaches the loop: `car_auth::AuthOperationError` separates
481/// `CoordinationDeadline` from `Terminal`, but inference collapses everything
482/// into one error string. Deliberately narrow — it must never swallow a genuine
483/// outage, because waiting for a human to fix a dead datacenter would hang the
484/// session rather than failing it.
485/// Poll until a credential appears, the caller cancels, or the window closes.
486///
487/// Returns `true` only when auth actually came back. Three things bound it, and
488/// all three matter: the `wait` window, an explicit cancel, and the **session
489/// deadline** — waiting for a human must never let a run outlive the ceiling
490/// its caller set, or "wait for sign-in" becomes a way to ignore a budget.
491async fn wait_for_auth(
492    gate: &dyn AuthGate,
493    wait: std::time::Duration,
494    cancel: &CancelFlag,
495    deadline: &SessionDeadline,
496) -> bool {
497    const POLL: std::time::Duration = std::time::Duration::from_secs(2);
498    let started = std::time::Instant::now();
499    loop {
500        if gate.is_authenticated().await {
501            return true;
502        }
503        if cancel.load(Ordering::SeqCst) || deadline.admit().is_some() || started.elapsed() >= wait
504        {
505            return false;
506        }
507        tokio::time::sleep(POLL).await;
508    }
509}
510
511/// What a [`CoderEventKind::ModelFallback`] says when the degrade was caused by
512/// a rejected credential. One constant so the coder loop and the two contract
513/// gates in `rpc.rs` word it identically (Parslee-ai/car#888).
514pub(crate) const MODEL_FALLBACK_REASON: &str =
515    "that lane needs sign-in (`car auth login`) — this run is continuing on a fallback model";
516
517/// The journal spelling of a [`car_inference::FallbackReason`].
518///
519/// Stable strings, because they are what a reader of `<id>.events.jsonl` and
520/// any offline miner match on — the enum's `Debug` is not a wire contract.
521pub(crate) fn fallback_reason_label(reason: car_inference::FallbackReason) -> &'static str {
522    use car_inference::FallbackReason as R;
523    // A second table in another crate drifts from the first silently, so these
524    // are pinned against the serde spelling by
525    // `the_journal_labels_match_the_serde_spelling` below rather than trusted.
526    match reason {
527        R::CredentialRejected => "credential_rejected",
528        R::CredentialAbsent => "credential_absent",
529        R::RateLimited => "rate_limited",
530        R::QuotaExhausted => "quota_exhausted",
531        R::TimedOut => "timed_out",
532        R::Failed => "failed",
533    }
534}
535
536/// Whether a failure means the run is blocked on **sign-in** rather than on the
537/// machinery — the one failure a person at the keyboard can clear in seconds.
538///
539/// Credential summary phrases and rejected-credential shapes are both owned by
540/// car-inference. Keeping this as a delegation means a new route summary cannot
541/// tell the operator to repair auth while the coder loop independently calls it
542/// retryable infrastructure (Parslee-ai/car#888, #1248).
543pub(crate) fn is_auth_failure(message: &str) -> bool {
544    car_inference::is_auth_failure_message(message)
545}
546
547/// The name of the model-invokable mid-session question tool. Recognized by the
548/// loop (not the `WorktreeExecutor`) so the channel plumbing stays with the
549/// sink + cancel flag the loop already holds.
550pub const ASK_USER_TOOL: &str = "ask_user";
551
552/// The no-change nomination tool. See [`super::no_change`] for what the runtime
553/// does with a call, and why almost all of that is refusal.
554pub const REPORT_NO_CHANGE_TOOL: &str = "report_no_change";
555
556/// How many times the SAME read-only call (tool + args) may repeat, with no
557/// intervening mutating call, before the attempt is treated as a no-progress
558/// thrash — the signature of a backbone that never returns its tool results, so
559/// the model re-reads the same thing forever without ever acting on it. Surfaced
560/// by dogfooding: a coder on a backbone that dropped tool history issued 210
561/// identical `read_file` calls and zero edits before the turn cap. On a trip the
562/// loop breaks to contract evaluation (earlier edits may already have passed);
563/// only a second no-progress iteration aborts the session.
564const NO_PROGRESS_REPEAT_LIMIT: u32 = 6;
565
566/// Read-only coder tools — repeating one changes nothing, so a run of identical
567/// read-only calls with no mutating call between them is the no-progress signal.
568/// Anything not listed here (edit_file/write_file/shell/… and any external tool)
569/// clears the guard when successful, so a legitimate read→edit or
570/// build→edit→build loop never trips — the safe-by-default direction.
571///
572/// Known, accepted gap: a thrash driven through `shell` (e.g. `cat foo`/`ls`
573/// every turn) is treated as progress and won't trip this guard — we can't tell
574/// a successful read-only shell from a mutating one without a heuristic that would
575/// re-introduce false positives on legitimate `shell` build/poll loops. Such a
576/// thrash still degrades to the bounded `max_turns` terminal, not the silent
577/// budget-burn. If ever worth closing, do it with a tool-agnostic backstop (an
578/// iteration with zero successful *mutating* calls is no-progress) rather than
579/// classifying shell command strings.
580fn is_read_only_tool(name: &str) -> bool {
581    matches!(name, "read_file" | "list_dir" | "find_files" | "grep_files")
582}
583
584/// Tool definition for [`ASK_USER_TOOL`], appended to the model-visible tool
585/// list only when an [`AskUser`] handler is wired.
586/// The tool the model calls to nominate "no code should change".
587///
588/// The description is deliberately discouraging about the two shapes that
589/// cannot terminate autonomously, and explicit that a session which has already
590/// edited is ineligible — a model that learns that from a refusal has already
591/// wasted a round.
592fn report_no_change_tool_def() -> Value {
593    serde_json::json!({
594        "name": REPORT_NO_CHANGE_TOOL,
595        "description": "Conclude that NO code should change, and end the session by \
596                        reporting that instead of producing a diff. Use this only when \
597                        you have investigated and established one of: the reported \
598                        problem does not exist (the code already handles it); the \
599                        behaviour is intentional; or the real fix is not a code change \
600                        at all. This is NOT a way to stop when the task is hard — a \
601                        session that has already edited any file cannot use it, and \
602                        reverting does not restore eligibility. Your conclusion is a \
603                        nomination: the runtime checks it against the baseline results \
604                        and may route it to a human rather than accept it.",
605        "parameters": {
606            "type": "object",
607            "properties": {
608                "kind": {
609                    "type": "string",
610                    "enum": ["premise_wrong", "deliberate_behavior", "non_code_decision"],
611                    "description": "premise_wrong: the reported problem does not exist. \
612                                    deliberate_behavior: the code does this on purpose. \
613                                    non_code_decision: a real problem whose fix is a \
614                                    migration, an operator decision, or a rollout."
615                },
616                "summary": {
617                    "type": "string",
618                    "description": "One line: the conclusion itself."
619                },
620                "evidence": {
621                    "type": "string",
622                    "description": "What you examined to reach it — files read, commands \
623                                    run, what they showed. A reviewer reads this to decide \
624                                    whether to believe you."
625                }
626            },
627            "required": ["kind", "summary", "evidence"]
628        }
629    })
630}
631
632/// Read a `report_no_change` call's arguments, or say precisely what is wrong.
633///
634/// Strict about `kind` on purpose: a mistyped kind silently falling back to
635/// `premise_wrong` would route a judgement call onto the one path that can
636/// terminate without a human.
637fn parse_nomination(params: &Value) -> Result<NoChangeNomination, String> {
638    let kind_str = params
639        .get("kind")
640        .and_then(Value::as_str)
641        .ok_or("ERROR: report_no_change requires `kind`")?;
642    let kind = NoChangeKind::parse(kind_str).ok_or_else(|| {
643        format!(
644            "ERROR: unknown kind '{kind_str}' — must be one of premise_wrong, \
645             deliberate_behavior, non_code_decision"
646        )
647    })?;
648    let summary = params
649        .get("summary")
650        .and_then(Value::as_str)
651        .ok_or("ERROR: report_no_change requires `summary`")?
652        .to_string();
653    let evidence = params
654        .get("evidence")
655        .and_then(Value::as_str)
656        .ok_or("ERROR: report_no_change requires `evidence`")?
657        .to_string();
658    Ok(NoChangeNomination {
659        kind,
660        summary,
661        evidence,
662    })
663}
664
665fn ask_user_tool_def() -> Value {
666    serde_json::json!({
667        "name": ASK_USER_TOOL,
668        "description": "Ask the human user a question and wait for their reply. \
669                        Use ONLY when you genuinely cannot proceed without a \
670                        decision or missing fact the user alone can supply (an \
671                        ambiguous requirement, a destructive choice, a missing \
672                        credential). Do not use it for things you can determine \
673                        by reading the repo or running commands. The call blocks \
674                        until the user answers or a timeout elapses; on timeout \
675                        you receive an error and should proceed with your best \
676                        judgment.",
677        "parameters": {
678            "type": "object",
679            "properties": {
680                "prompt": {
681                    "type": "string",
682                    "description": "The question to show the user, phrased so a short reply answers it."
683                }
684            },
685            "required": ["prompt"]
686        }
687    })
688}
689
690fn apply_operator_guidance(
691    cfg: &NativeLoopConfig,
692    messages: &mut Vec<Message>,
693    sink: &EventSink,
694) -> bool {
695    let queued = cfg
696        .steering
697        .as_ref()
698        .map(|inbox| inbox.take())
699        .unwrap_or_default();
700    let applied = !queued.is_empty();
701    for text in queued {
702        messages.push(Message::User {
703            content: format!("Operator guidance for the current task (the approved checks and execution permissions remain unchanged):\n{text}"),
704        });
705        sink.emit(CoderEventKind::OperatorGuidance {
706            text,
707            status: "applied".into(),
708        });
709    }
710    applied
711}
712
713/// Tuning for the native loop.
714#[derive(Debug, Clone)]
715pub struct NativeLoopConfig {
716    pub steering: Option<Arc<super::steering::SteeringInbox>>,
717    /// Pinned model id; `None` routes adaptively (TaskHint::Code).
718    pub model: Option<String>,
719    /// Models the adaptive arm must not use. Ignored when [`Self::model`] is
720    /// pinned: a caller's explicit model is either honored or refused by its
721    /// own admission gate, never silently routed around.
722    pub exclude_models: Vec<String>,
723    /// Contract-evaluation rounds before giving up.
724    pub max_iterations: u32,
725    /// Model turns within one iteration before forcing evaluation.
726    pub max_turns_per_iteration: u32,
727    /// Generation budget per turn.
728    pub max_tokens_per_turn: usize,
729    /// Extra guidance appended to the system prompt, set by the harness
730    /// evolution loop (car#708). `None` = the byte-identical prompt the coder
731    /// used before overlays existed.
732    pub prompt_overlay: Option<String>,
733    /// The session's absolute deadline, SHARED with every other rung of the
734    /// fallback ladder. An `Arc` rather than a value so a rung cannot restart a
735    /// clock it does not own — the defect that made the first version a
736    /// per-loop ceiling calling itself a session one.
737    pub deadline: Arc<SessionDeadline>,
738    /// Lets a session blocked on sign-in wait for the human rather than dying.
739    /// `None` keeps the previous behaviour byte for byte.
740    pub auth_gate: Option<Arc<dyn AuthGate>>,
741    /// Whether this caller can adjudicate a no-change nomination — i.e. it holds
742    /// the baseline results, the contract's provenance and the mutation ledger,
743    /// and will run [`super::no_change::evaluate_nomination`] on whatever the
744    /// loop hands back.
745    ///
746    /// The `report_no_change` tool is offered ONLY when this is set. A caller
747    /// that cannot judge a nomination must not invite one: `LoopOutcome` would
748    /// come back neither green nor failed, and a caller not looking for that
749    /// third shape reads it as an ordinary red and silently discards the
750    /// finding. Advertising a tool nobody can honour is worse than not having
751    /// it.
752    ///
753    /// Defaults to **false**, which is the correct answer for every caller that
754    /// has not been taught the third shape — this is a capability declaration,
755    /// not a behaviour switch.
756    pub can_adjudicate_no_change: bool,
757    /// How long to wait for the human to re-authenticate before giving up.
758    ///
759    /// Generous because it bounds a *person*, not a process — they may be away
760    /// from the machine. The session deadline still applies on top, so this can
761    /// never extend a run past its own ceiling.
762    pub auth_wait: std::time::Duration,
763    /// The session-start baseline captures differential checks compare against
764    /// (car#1067). Empty when the contract declares none — and under an empty
765    /// map a differential check fails closed with a "never captured" message
766    /// rather than passing silently.
767    pub baseline_captures: super::contract::BaselineCaptures,
768}
769
770impl Default for NativeLoopConfig {
771    fn default() -> Self {
772        Self {
773            model: None,
774            steering: None,
775            exclude_models: Vec::new(),
776            max_iterations: 8,
777            max_turns_per_iteration: 24,
778            max_tokens_per_turn: 4096,
779            prompt_overlay: None,
780            deadline: SessionDeadline::shared_default(),
781            auth_gate: None,
782            auth_wait: std::time::Duration::from_secs(600),
783            can_adjudicate_no_change: false,
784            baseline_captures: crate::coder::contract::BaselineCaptures::new(),
785        }
786    }
787}
788
789impl NativeLoopConfig {
790    /// Fold the general harness knobs the Evolution Agent tunes
791    /// ([`car_memgine::HarnessConfig`]) onto the coder's own budgets, so a
792    /// harness patch applied through `evolution.run`'s gated path actually
793    /// changes coder behavior. The coder's native loop does NOT read
794    /// `HarnessConfig` directly (it lives on the general `car_engine::Runtime`
795    /// executor), so without this an applied patch would be inert and the A/B
796    /// improvement loop could never converge. Mapping: `planning_max_replans`
797    /// (how many replan/repair rounds the runtime grants) → the coder's
798    /// `max_iterations` (contract-eval repair rounds); `max_retries` (per-action
799    /// retry budget) → a floor on `max_turns_per_iteration`. **Only ever RAISES
800    /// a budget** — a harness fix grants headroom; it never starves the coder
801    /// below its base config.
802    ///
803    /// The session wall clock (`deadline`) is deliberately NOT raised here. It
804    /// is an operator-owned safety ceiling, not a tuning knob, and the Evolution
805    /// Agent granting itself more wall time would defeat the one bound that
806    /// stops a runaway session. The consequence is real and worth stating: a
807    /// patch raising `max_iterations` 8 -> 16 can be capped by a wall clock the
808    /// harness cannot touch, so a converged-looking A/B result may in fact have
809    /// been cut off. Raise `max_session_wall_secs` in `~/.car/coder.toml` when
810    /// running long-budget experiments.
811    pub fn merge_harness(&mut self, h: &car_memgine::HarnessConfig) {
812        self.max_iterations = self
813            .max_iterations
814            .max(h.planning_max_replans.saturating_add(1));
815        self.max_turns_per_iteration = self.max_turns_per_iteration.max(h.max_retries);
816        // The prompt overlay (car#708). Unlike the budgets above this is not a
817        // max() — an overlay is either in force or it is not, and a half-applied
818        // one is meaningless. Rolling it back is the inverse patch clearing it.
819        self.prompt_overlay = h.prompt_overlay.clone();
820    }
821}
822
823/// Why a loop run ended without green checks.
824///
825/// One axis: "what stopped this from passing". **Descriptive, not
826/// prescriptive** — each variant records what happened, never what to do next.
827/// See [`LoopOutcome::failure`] for why, and do not read a retry instruction
828/// out of any variant here.
829///
830/// Produced by four modules (`external_loop`, `native_loop`, `foreman_loop`,
831/// `rpc`), so "the worker" below means whatever ran the work — an external CLI,
832/// the native inference loop, or a foreman farm-out.
833///
834/// The first two are knowable *before* the contract can say anything: no work
835/// was attempted, so there is nothing to evaluate. The rest describe a run that
836/// produced work. Where a loop can evaluate the contract it does so before
837/// classifying — a transport that dies mid-run must not pronounce a session
838/// failed while the contract might already be green, because the worktree is
839/// the state, not the process. (`rpc`'s agent-build path is the exception: it
840/// has no shell contract to evaluate, only scenario results.)
841#[derive(Debug, Clone, Copy, PartialEq, Eq)]
842pub enum LoopFailure {
843    /// The worker could not be started, or never received the task — missing
844    /// binary, unready CLI, pipes or prompt delivery that failed before handoff.
845    /// Nothing was attempted, so the caller is free to try a different engine.
846    EngineUnavailable,
847    /// The user cancelled. Terminal, and explicitly NOT a fallback trigger:
848    /// substituting another engine would run work the human just stopped.
849    Cancelled,
850    /// The machinery failed rather than the work: an invocation that died
851    /// mid-run, or a backbone that stopped answering. Edits may be partially
852    /// applied.
853    Infrastructure,
854    /// The configured routing constraints leave no model eligible. No provider
855    /// was invoked and no work was attempted, so this belongs to daemon
856    /// configuration rather than the item being healed.
857    Configuration,
858    /// The run needs the human to sign in, and nobody did within the window.
859    ///
860    /// Split out of `Infrastructure` because the two call for opposite
861    /// responses: an outage is not worth waiting on, whereas this resolves in
862    /// seconds if someone is asked. Folding them together is what made an
863    /// expired 15-minute token discard a 29-minute session — the run did not
864    /// need to end, it needed to ask. Edits survive in the worktree; re-running
865    /// after `car auth login` resumes from there.
866    NeedsAuth,
867    /// The worker ran, reported its own error, and the checks are still red.
868    Execution,
869    /// The worker ran clean and the checks are still red — the implementation
870    /// was wrong, not the machinery.
871    Verification,
872    /// A loop hit its wall-clock ceiling and the next iteration was not
873    /// admitted.
874    ///
875    /// Distinct from `Verification` because a harness-imposed cut is not a task
876    /// loss, and a scorer needs to tell them apart: `ab::ArmOutcome::scorable`
877    /// exists precisely to keep "the harness stopped it" out of the scored
878    /// denominator, and the alternative to a typed cause is another compare
879    /// against error prose — which this enum was introduced to end.
880    ///
881    /// It does NOT protect the recurrence/skill machinery, despite the obvious
882    /// guess: `record_failure` and `record_recurrence` run at the END of a red
883    /// iteration, while admission is denied at the START of the next one, so a
884    /// cut-off approach has already been written to skill memory as a failure
885    /// before this variant exists. Fixing that is a separate change.
886    BudgetExhausted,
887}
888
889/// How a loop run ended.
890///
891/// Build these with [`LoopOutcome::green`] and [`LoopOutcome::lost`] rather than
892/// a struct literal. The `failure`-is-`None`-exactly-when-`passed` invariant is
893/// documented on the field and held at every site today, but nothing enforced
894/// it — a struct literal lets the next site quietly state that a run both passed
895/// and failed. The constructors make that unrepresentable.
896#[derive(Debug, Clone)]
897pub struct LoopOutcome {
898    /// Every contract check passed.
899    pub passed: bool,
900    /// Iterations actually executed.
901    pub iterations: u32,
902    /// Check results from the final evaluation.
903    pub last_results: Vec<CheckResult>,
904    /// Human-readable terminal error. `None` on a pass, and on a loss the loop
905    /// attributes to the work rather than the machinery — an exhausted budget
906    /// whose last run was clean leaves this empty and lets `rpc` render
907    /// "contract not satisfied after N iteration(s)".
908    ///
909    /// It is NOT empty merely because the budget ran out: an exhausted
910    /// [`LoopFailure::Infrastructure`] still fills this in, deliberately (see
911    /// the scraping note below).
912    ///
913    /// Human-readable, and load-bearing beyond humans: `car-cli`'s coder-A/B
914    /// scrapes this text across a process boundary to split infra failures out
915    /// of the scored denominator (`coder_ab::INFRA_MARKERS`). Keep the
916    /// `external agent '<id>'` / `foreman adapter '<id>'` prefixes and the bare
917    /// `cancelled` spelling stable, or that split breaks silently.
918    pub error: Option<String>,
919    /// Typed reason the run is not green; `None` exactly when `passed`.
920    ///
921    /// Exists so callers branch on a value instead of matching on `error`'s
922    /// prose — `rpc`'s engine fallback used to be an `e != "cancelled"` string
923    /// compare.
924    ///
925    /// **Descriptive, not prescriptive.** It records what went wrong, not what
926    /// to do about it. Retry policy belongs to whichever loop produced the
927    /// value and knows its own budgets: `external_loop` retries an
928    /// `Infrastructure` failure it has budget for, while the same variant from
929    /// `native_loop` means the loop already retried internally and gave up. Do
930    /// not write `if failure == Infrastructure { retry }` at an outer level.
931    pub failure: Option<LoopFailure>,
932    /// A no-change finding the loop nominated and the runtime accepted or
933    /// parked. `Some` means the run ended WITHOUT a diff and without that being
934    /// a failure — see [`super::no_change`].
935    ///
936    /// This is the one case where `passed` is false and `failure` is also
937    /// `None`, which relaxes the invariant stated above: `failure` is `None`
938    /// exactly when `passed` **or** `nomination.is_some()`. A nomination is
939    /// neither a green contract nor a loss, and forcing it into either is the
940    /// whole defect #1070 describes.
941    pub nomination: Option<NoChangeNomination>,
942    /// Metered inference spend for this run, when anything reported it.
943    ///
944    /// `None` means **unknown**, not free — the native loop does not meter, and
945    /// conflating the two is how `ab::ArmOutcome.cost_usd` came to publish a
946    /// structural zero: `cost_per_pass` was computed as `sum / passes` over a
947    /// field nothing ever filled, so every external arm reported a cost of
948    /// exactly $0.00 as though it were measured.
949    pub cost_usd: Option<f64>,
950}
951
952impl LoopOutcome {
953    /// Attach metered spend. Separate from the constructors so adding cost
954    /// accounting did not churn all 24 construction sites, and so a caller with
955    /// nothing to report simply never calls it.
956    pub fn with_cost(mut self, usd: Option<f64>) -> Self {
957        self.cost_usd = usd;
958        self
959    }
960
961    /// Every check passed. `failure` is `None` by construction.
962    pub fn green(iterations: u32, last_results: Vec<CheckResult>) -> Self {
963        Self {
964            passed: true,
965            iterations,
966            last_results,
967            error: None,
968            failure: None,
969            nomination: None,
970            cost_usd: None,
971        }
972    }
973
974    /// The run ended without a diff, and correctly so — the runtime accepted or
975    /// parked a no-change nomination. Neither green nor lost; see the
976    /// `nomination` field for why that third shape has to exist.
977    pub fn reported(
978        finding: NoChangeNomination,
979        iterations: u32,
980        last_results: Vec<CheckResult>,
981    ) -> Self {
982        Self {
983            passed: false,
984            iterations,
985            last_results,
986            error: None,
987            failure: None,
988            nomination: Some(finding),
989            cost_usd: None,
990        }
991    }
992
993    /// The run ended not-green, for `failure`.
994    ///
995    /// `error` is the human-readable text, and stays `None` when the loss needs
996    /// no explanation beyond the checks themselves — `rpc` then renders
997    /// "contract not satisfied after N iteration(s)". It is NOT decoration:
998    /// `car-cli`'s A/B scrapes it across a process boundary, so the
999    /// `external agent '<id>'` prefix and the bare `cancelled` spelling are load-
1000    /// bearing where they appear.
1001    pub fn lost(
1002        failure: LoopFailure,
1003        error: Option<String>,
1004        iterations: u32,
1005        last_results: Vec<CheckResult>,
1006    ) -> Self {
1007        Self {
1008            passed: false,
1009            iterations,
1010            last_results,
1011            error,
1012            failure: Some(failure),
1013            nomination: None,
1014            cost_usd: None,
1015        }
1016    }
1017}
1018
1019fn preview(s: &str, max: usize) -> String {
1020    if s.len() <= max {
1021        return s.to_string();
1022    }
1023    let mut end = max;
1024    while !s.is_char_boundary(end) {
1025        end -= 1;
1026    }
1027    format!("{}…", &s[..end])
1028}
1029
1030/// Render the coder's system prompt, with any evolved overlay appended.
1031///
1032/// The base prompt stays in source and the overlay can only *add* a section.
1033/// That asymmetry is what makes a prompt safe to evolve: an overlay able to
1034/// replace the prompt could delete the rules the prompt is carrying — the
1035/// `git commit` prohibition, for one, is held by prose and nothing else — and
1036/// no regression gate reliably catches a rule that silently stopped being
1037/// stated. It is rendered last, clearly delimited and explicitly subordinate,
1038/// so a conflicting instruction reads as an addition to the rules above rather
1039/// than a replacement for them.
1040fn system_prompt_with_overlay(
1041    contract: &OutcomeContract,
1042    environment: &str,
1043    project: Option<&str>,
1044    overlay: Option<&str>,
1045) -> String {
1046    let base = system_prompt(contract, environment, project);
1047    match overlay.map(str::trim).filter(|o| !o.is_empty()) {
1048        None => base,
1049        Some(overlay) => format!(
1050            "{base}\n\n\
1051             ADDITIONAL GUIDANCE (learned from prior sessions; it ADDS to the rules \
1052             above and never overrides them — if it appears to conflict with anything \
1053             above, the rules above win):\n{overlay}"
1054        ),
1055    }
1056}
1057
1058fn system_prompt(contract: &OutcomeContract, environment: &str, project: Option<&str>) -> String {
1059    // Placed AFTER the environment and BEFORE the how-to-work rules, because
1060    // these are constraints on how the work is done, not facts about the
1061    // machine. Empty when the repo carries neither instructions nor a `.car/`
1062    // project, so a repo with nothing gets a byte-identical prompt to before.
1063    let project_block = project
1064        .map(str::trim)
1065        .filter(|p| !p.is_empty())
1066        .map(|p| format!("{p}\n\n"))
1067        .unwrap_or_default();
1068    format!(
1069        "You are CAR Coder, an autonomous coding agent working in an isolated git worktree \
1070         of the user's repository. The worktree root is your working directory; all relative \
1071         paths resolve against it.\n\n\
1072         ENVIRONMENT:\n{environment}\n\n\
1073         {project_block}\
1074         How to work:\n\
1075         - Inspect before you edit. Read the relevant files and search the codebase \
1076           (grep_files / find_files) to understand the code BEFORE changing it. Never \
1077           fabricate file contents, symbols, or APIs you have not actually read.\n\
1078         - Plan briefly, then make surgical edits: prefer edit_file for targeted changes \
1079           over rewriting a whole file with write_file. Change the minimum the task needs.\n\
1080         - Trace the checks before you declare done. Read each outcome-contract check and \
1081           confirm your change actually makes it pass — the exact expected values, and \
1082           every symbol the check exercises.\n\
1083         - Verify your own work by running the EXACT command(s) from the OUTCOME CONTRACT \
1084           below, verbatim — copy the command string character-for-character (same \
1085           interpreter path, same flags, same scoped test file). Do NOT substitute a \
1086           broader or 'equivalent' command: running `python -m pytest tests/` when the \
1087           contract says `/path/to/venv/bin/python -m pytest -q tests/test_x.py` is WRONG \
1088           — a different interpreter (e.g. a system `python` that is a different version \
1089           with different installed packages) can fail on environment issues that have \
1090           nothing to do with your task. Read that command's real output before declaring \
1091           done; the contract's exact command is the only thing that decides done. Never \
1092           claim a check passed without having run its exact command this session and seen \
1093           it pass.\n\
1094         - The environment is not yours to fix. If the contract's exact command fails on \
1095           something that is not your code — a version mismatch, a missing package, an \
1096           import error in an unrelated module, a broken runner — your code fix is already \
1097           done: write your summary and STOP. The runtime re-runs the contract in the \
1098           correct environment to decide done, so turns spent making a wrong-environment \
1099           command pass cannot change the verdict. (Package installs, venv creation, and \
1100           interpreter shims are denied by policy; you will get a denial with a reason.)\n\
1101         - If the shell tool is unavailable or a command is blocked this session (e.g. a \
1102           permission-restricted runner returns an approval error instead of output), that \
1103           is NOT a task failure and NOT a reason to report the work as blocked or uncertain: \
1104           the runtime independently runs the outcome contract to decide done. Make your edits \
1105           correct, note that you could not self-run the checks, and STOP — do not retry the \
1106           blocked command in a loop.\n\
1107         - On failure, read the actual error output before retrying — fix the specific \
1108           cause the compiler or test named; do not guess-and-retry. If the error names a \
1109           missing symbol, function, or attribute, IMPLEMENT it rather than editing the \
1110           caller. If the same check fails again after an edit, your hypothesis was wrong: \
1111           re-read the exact expected-vs-actual and form a different one — do not re-apply a \
1112           variation of an edit that did not change the failure.\n\n\
1113         - Do not git commit; the runtime handles version control. Do not publish the work \
1114           yourself by any route — `git push`, `gh pr create`, `gh release create`, \
1115           `npm publish`, `cargo publish` and the like are denied; the runtime opens the \
1116           pull request itself once the merge gate is approved. Read-only forge commands \
1117           (`gh pr view`, `gh run view`, `gh api` GET) stay available. (`sudo` and \
1118           destructive operations outside the worktree are denied too. Every denial comes \
1119           back with a reason; don't retry a denied call verbatim.)\n\n\
1120         When you believe the work is complete, reply with a brief plain-text summary and \
1121         STOP calling tools. The runtime independently re-runs the outcome contract after \
1122         you stop — but do not rely on it: verify the checks yourself first, because a red \
1123         re-invocation costs a full round-trip.\n\n\
1124         OUTCOME CONTRACT (the runtime runs these to decide done):\n{}",
1125        contract.render()
1126    )
1127}
1128
1129/// Feedback injected as a new user turn on a red repair round. `recurrences` is
1130/// how many EARLIER rounds this round's primary [`FailureSignature`] has already
1131/// been seen in (0 the first time that exact failure appears). It escalates the
1132/// repair discipline: on a fresh failure, direct the model to read the specific
1133/// error and fix the named cause; on a recurring one, tell it its approach isn't
1134/// working so it stops re-applying a variation of a non-converging edit.
1135/// Surfaced by the coder A/B on hard real-codebase tasks, where the coder edited
1136/// 20+ times across 6 rounds but never addressed the actual cause (a missing
1137/// function the test named).
1138///
1139/// Keyed on the signature (check + coarse error class), not the bare check name,
1140/// and counted by total recurrence rather than consecutive streak. Both changes
1141/// remove a wrong answer:
1142/// - **Name-only over-fires.** A check going `compile_error` -> `test_failure`
1143///   is real progress — the code now builds — but the name never changed, so a
1144///   name-keyed streak told the model its approach was not addressing the cause
1145///   at the exact moment it was.
1146/// - **Consecutive-only under-fires.** A coder alternating between two bad
1147///   fixes (A -> B -> A -> B) resets a streak counter every round and never
1148///   escalates, which is precisely the non-convergence worth interrupting.
1149///
1150/// Known limit: [`super::skill_memory`]'s `classify` names three buckets and
1151/// collapses everything else to `exit_<code>`, so the gain is toolchain-
1152/// dependent. Rust and pytest emit the substrings it looks for; jest, `go test`,
1153/// eslint and mypy mostly do not, and for those the signature degrades to
1154/// check-name-plus-a-constant — the old key under a new name. Widening
1155/// `classify` is where further accuracy lives, not here.
1156fn failure_feedback(results: &[CheckResult], recurrences: u32) -> String {
1157    let mut msg = String::from(
1158        "The outcome contract was evaluated and some checks FAILED. Fix the code so they pass.\n\n",
1159    );
1160    for r in results.iter().filter(|r| !r.passed) {
1161        msg.push_str(&format!(
1162            "FAILED {} (exit {:?}):\n{}\n\n",
1163            r.name, r.exit_code, r.output_tail
1164        ));
1165    }
1166    if recurrences == 0 {
1167        msg.push_str(
1168            "Before editing again: read the SPECIFIC failure above — the exact assertion, error \
1169             type, or traceback line — and name the single cause. If the error names a missing \
1170             symbol/function/attribute, implement THAT symbol. Find the code responsible for the \
1171             named cause and fix it directly; do not guess-and-retry.\n",
1172        );
1173    } else {
1174        msg.push_str(&recurrence_notice(recurrences));
1175    }
1176    msg
1177}
1178
1179/// The escalation clause, shared by both loops so they cannot drift into making
1180/// different claims about the same integer.
1181///
1182/// Deliberately modest about what it knows. The key is a check name plus one of
1183/// five coarse buckets from `skill_memory::classify`, and outside rustc/pytest
1184/// shapes almost everything collapses to `exit_<code>` — so two unrelated
1185/// failures that both exit 1 share a signature. Claiming "this EXACT failure"
1186/// would assert precision the key cannot back, to a model that can see the
1187/// output and check. "The same check has failed the same way" is what the data
1188/// supports.
1189///
1190/// It also must not say "in a row": the count is a session total, so in the
1191/// oscillation case this mechanism exists to catch (A -> B -> A) the failures
1192/// demonstrably were not consecutive, and the model has the transcript.
1193pub(super) fn recurrence_notice(recurrences: u32) -> String {
1194    format!(
1195        "The same check has now failed the same way {} times in this session (not necessarily \
1196         in consecutive rounds) despite your edits — your approach is NOT addressing the real \
1197         cause, so do NOT re-apply a variation of the same edit. STOP and read the failure \
1198         literally: what exact value or behavior was EXPECTED vs what was PRODUCED? Trace that \
1199         exact value back to the specific code that produces it, form a DIFFERENT hypothesis \
1200         about the named cause, and make one targeted change to it. If the error names a missing \
1201         symbol/function/attribute, the fix is to IMPLEMENT it, not to adjust the caller.\n",
1202        recurrences + 1
1203    )
1204}
1205
1206/// How many EARLIER rounds this signature has already appeared in, recording it
1207/// for the next round. `None` (a green evaluation) is not a recurrence.
1208///
1209/// The `None` arm is defensive, not reachable: both callers run only after a red
1210/// evaluation, so at least one check failed and `primary_failure` returns `Some`.
1211///
1212/// A count rather than a bool so the escalated feedback can state the number
1213/// back to the model — "this has now failed N times" is a materially stronger
1214/// instruction than "this has failed before".
1215pub(super) fn record_recurrence(
1216    seen: &mut HashMap<String, u32>,
1217    sig: Option<&FailureSignature>,
1218) -> u32 {
1219    let Some(sig) = sig else { return 0 };
1220    let entry = seen.entry(sig.key()).or_insert(0);
1221    let prior = *entry;
1222    *entry += 1;
1223    prior
1224}
1225
1226/// The signature of the most-relevant failure in a red evaluation: the first
1227/// failing check. One signature per repair round keeps learning attributable.
1228pub(super) fn primary_failure(results: &[CheckResult]) -> Option<FailureSignature> {
1229    results
1230        .iter()
1231        .find(|r| !r.passed)
1232        .map(FailureSignature::from_check)
1233}
1234
1235/// Append a recalled repair hint to the repair prompt. Kept terse and clearly
1236/// labelled as a heuristic from a prior session so the model treats it as a
1237/// lead, not gospel.
1238fn append_recall_hint(prompt: &mut String, hint: &str) {
1239    prompt.push_str(
1240        "\nHINT — a prior session resolved this same failure signature with this approach; \
1241         use it as a lead, verify it still applies:\n",
1242    );
1243    prompt.push_str(hint);
1244    prompt.push('\n');
1245}
1246
1247fn message_memory_text(message: &Message) -> Option<String> {
1248    match message {
1249        Message::System { content }
1250        | Message::User { content }
1251        | Message::Assistant { content, .. }
1252        | Message::ToolResult { content, .. } => {
1253            let trimmed = content.trim();
1254            (!trimmed.is_empty()).then(|| trimmed.to_string())
1255        }
1256        Message::UserMultimodal { content } => {
1257            let text = content
1258                .iter()
1259                .filter_map(|block| match block {
1260                    car_inference::ContentBlock::Text { text } => Some(text.trim()),
1261                    _ => None,
1262                })
1263                .filter(|s| !s.is_empty())
1264                .collect::<Vec<_>>()
1265                .join("\n");
1266            (!text.is_empty()).then_some(text)
1267        }
1268        _ => None,
1269    }
1270}
1271
1272fn append_context_block(req: &mut GenerateRequest, title: &str, body: &str) {
1273    let block = format!("## {title}\n{body}");
1274    req.context = Some(match req.context.take() {
1275        Some(existing) if !existing.trim().is_empty() => format!("{existing}\n\n{block}"),
1276        _ => block,
1277    });
1278}
1279
1280async fn maybe_apply_coder_proactive_memory(
1281    req: &mut GenerateRequest,
1282    intent: &str,
1283    messages: &[Message],
1284    sink: &EventSink,
1285    memory: &RepairMemory,
1286) {
1287    let mut recent = messages
1288        .iter()
1289        .rev()
1290        .filter_map(message_memory_text)
1291        .take(6)
1292        .collect::<Vec<_>>();
1293    recent.reverse();
1294    let events = sink.events();
1295    let Some((maintenance, decision)) = memory.proactive_for_task(intent, recent, &events).await
1296    else {
1297        return;
1298    };
1299    sink.record_proactive_memory(&maintenance, &decision);
1300    if let car_memgine::ProactiveMemoryDecision::Inject { reminder, .. } = decision {
1301        append_context_block(req, "Proactive Memory", &reminder);
1302    }
1303}
1304
1305/// Distill a durable, reusable approach summary from the iteration that turned
1306/// the contract green. The model's closing plan text (its own summary of what
1307/// it did) is the best signal; fall back to a generic marker when it stayed
1308/// silent so the skill still records that *something* fixed this signature.
1309fn winning_approach(sig: &FailureSignature, plan_text: &str) -> String {
1310    let plan = plan_text.trim();
1311    if plan.is_empty() {
1312        format!(
1313            "Re-attempted the edit; the '{}' failure of check '{}' cleared after repair.",
1314            sig.error_class, sig.check
1315        )
1316    } else {
1317        preview(plan, 1024)
1318    }
1319}
1320
1321/// The tool list a coding session advertises to the model: the worktree
1322/// executor's static built-ins, plus [`ASK_USER_TOOL`] when the caller supplied
1323/// an [`AskUser`] handler.
1324///
1325/// Deliberately [`WorktreeExecutor::tool_defs`] and **not**
1326/// `executor.all_tool_defs()`. Taking the whole delegate surface would sweep in
1327/// the Parslee platform tools, which need a sign-in a coding run does not have.
1328/// The delegate tools a coder *should* see are added back by name in
1329/// [`run_native_loop`] — graph-memory `recall` (car#1071) and the governed
1330/// network pair (car#1073) — so what the model was offered stays a list a reader
1331/// can enumerate rather than whatever happens to be attached. Because this part
1332/// is derived from the type rather than the instance, the daemon's `coder.*`
1333/// loop and headless `car code-task` show the model the same built-ins
1334/// (Parslee-ai/car#1063). Changing that is a design decision, not a cleanup.
1335pub(crate) fn native_loop_tool_defs(with_ask: bool) -> Vec<Value> {
1336    let mut tools = WorktreeExecutor::tool_defs();
1337    if with_ask {
1338        tools.push(ask_user_tool_def());
1339    }
1340    tools
1341}
1342
1343/// Every tool schema the native coder can advertise across its optional run
1344/// modes. Individual runs still filter `ask_user`, `report_no_change`, and
1345/// graph-memory recall according to the handlers and policy they carry.
1346pub fn model_tool_catalog() -> Vec<Value> {
1347    let mut tools = native_loop_tool_defs(true);
1348    tools.push(report_no_change_tool_def());
1349    tools.extend(
1350        crate::assistant::memory::MemoryTools::tool_defs()
1351            .into_iter()
1352            .filter(|tool| tool["name"] == "recall"),
1353    );
1354    // The inventory is the union across optional run modes. A real session
1355    // still receives these only after its explicit browser opt-in.
1356    tools.extend(
1357        crate::assistant::browser_tools::BrowserTools::new(std::env::temp_dir()).tool_defs(),
1358    );
1359    tools.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str()));
1360    tools.dedup_by(|left, right| left["name"] == right["name"]);
1361    tools
1362}
1363
1364/// The network tools a coding session offers: `http_request` and `web_search`,
1365/// attached to every coder executor by
1366/// [`WorktreeExecutor::for_coder_session`] (car#1073).
1367///
1368/// `granted` is the operator's answer — [`WorktreeExecutor::permits_full_access`]
1369/// at the call site — and it is passed in rather than read here on purpose. It
1370/// resolves from the per-agent approval policy, a process-global file, so a test
1371/// that wanted to drive both sides of this could only do it by mutating the
1372/// environment out from under every test running beside it. Splitting the
1373/// decision from the lookup keeps the offered list testable either way.
1374pub(crate) fn network_tool_defs(executor: &WorktreeExecutor, granted: bool) -> Vec<Value> {
1375    if !granted {
1376        return Vec::new();
1377    }
1378    ["http_request", "web_search"]
1379        .iter()
1380        .flat_map(|name| executor.delegate_defs_named(name))
1381        .collect()
1382}
1383
1384/// Everything a coding session offers the model, assembled in one place.
1385///
1386/// [`run_native_loop`] does not build this list itself — it calls this. That is
1387/// the point: a test that hand-rolls "what the loop offers" proves only that the
1388/// hand-rolled copy is right, and goes green through exactly the change it was
1389/// written to catch. #1063 extracted the static built-ins for that reason; this
1390/// finishes the job for the delegate half.
1391///
1392/// Three additions on top of [`native_loop_tool_defs`]:
1393///
1394/// - The no-change nomination tool, only for a caller that can adjudicate one.
1395/// - Graph-memory `recall` (car#1071), when the executor carries it. Only
1396///   `recall` is ever attached to a coder session — see `recall_only_memory_defs`
1397///   for why the write half is withheld.
1398/// - The governed network pair (car#1073), only once the operator has granted
1399///   `car-coder` the `full_access` tier. A tool in the prompt that the gate will
1400///   hard-block costs the model a turn and teaches it the wrong thing about the
1401///   task, so the grant is read here rather than discovered at dispatch.
1402/// - The assistant's browser surface (car#1069), only when the executor carries
1403///   it because this session explicitly opted in. The opt-in is the per-session
1404///   approval for those exact full-access tools; a configured Deny still wins.
1405///
1406/// `advertise_delegates` opens delegate dispatch, and is called beside each
1407/// addition so a name is never offered without being callable. Note what it does
1408/// NOT do: it is a single flag for every attached delegate, not a per-tool one,
1409/// and `recall` is attached unconditionally — so by the time the network branch
1410/// runs, dispatch is already open. Containment for the network pair is the
1411/// `full_access` tier those defs declare and the per-agent gate that reads it,
1412/// not this flag. Do not add a side-effecting delegate here on the belief that
1413/// leaving it unadvertised keeps it unreachable.
1414pub(crate) fn coder_session_tool_defs(
1415    executor: &WorktreeExecutor,
1416    with_ask: bool,
1417    can_adjudicate_no_change: bool,
1418) -> Vec<Value> {
1419    let mut tools = native_loop_tool_defs(with_ask);
1420    if can_adjudicate_no_change {
1421        tools.push(report_no_change_tool_def());
1422    }
1423    let memory_defs = executor.delegate_defs_named("recall");
1424    if !memory_defs.is_empty() {
1425        executor.advertise_delegates();
1426        tools.extend(memory_defs);
1427    }
1428    let net_defs = network_tool_defs(executor, executor.permits_full_access());
1429    if !net_defs.is_empty() {
1430        executor.advertise_delegates();
1431        tools.extend(net_defs);
1432    }
1433    let mut browser_defs = executor.delegate_defs_with_prefix("browse_");
1434    browser_defs.extend(executor.delegate_defs_with_prefix("browser_"));
1435    if !browser_defs.is_empty() {
1436        executor.advertise_delegates();
1437        tools.extend(browser_defs);
1438    }
1439    // Withhold what operator policy forbids outright.
1440    //
1441    // The inspector chain already refuses these at dispatch, so this is not a
1442    // new control — it stops the loop paying for one. A tool no call can
1443    // satisfy costs a schema on every request and a whole turn when the model
1444    // tries it, and a coding loop has a turn budget. Narrowing only: nothing
1445    // here can make a denied tool callable.
1446    //
1447    // Empty unless the executor came from `for_coder_session`, which is the
1448    // only constructor that loads operator rules.
1449    let denied = executor.denied_tools();
1450    if !denied.is_empty() {
1451        tools.retain(|def| !denied.contains(def["name"].as_str().unwrap_or_default()));
1452        // Same reasoning as the assistant's: withholding a tool removes the
1453        // operator's only incidental proof that the rule loaded. Every blocked
1454        // attempt used to leave a denial behind; a model that is never offered
1455        // the tool never attempts it, so a policy that silently failed to load
1456        // now looks exactly like one working perfectly. Said once per run, not
1457        // per turn — this function is called once, before the turn loop.
1458        //
1459        // A log line, not an event-log record, for the reason given at the
1460        // assistant's copy: no `EventKind` means "withheld at assembly", and
1461        // adding one changes a serialized shape that crosses every binding.
1462        tracing::info!(
1463            withdrawn = ?denied,
1464            "operator policy denies these tools outright; withheld from the coding loop's \
1465             advertised list (still refused at dispatch by the inspector chain)"
1466        );
1467    }
1468    tools
1469}
1470
1471/// Drive the native loop to completion, cancellation, or exhaustion.
1472///
1473/// When `ask` is `Some`, the model is additionally offered the [`ASK_USER_TOOL`]
1474/// to request mid-session input; the loop routes that one tool to the handler
1475/// (not the worktree executor) so a question blocks on the user-input gate while
1476/// honoring cancellation and the gate's timeout.
1477#[allow(clippy::too_many_arguments)]
1478pub async fn run_native_loop(
1479    inference: &dyn TurnGenerator,
1480    executor: &WorktreeExecutor,
1481    intent: &str,
1482    contract: &OutcomeContract,
1483    sink: &EventSink,
1484    cancel: &CancelFlag,
1485    cfg: &NativeLoopConfig,
1486    memory: &RepairMemory,
1487    ask: Option<&dyn AskUser>,
1488) -> LoopOutcome {
1489    let _steering_scope = cfg.steering.as_ref().map(|inbox| inbox.enter(sink));
1490    let tools = coder_session_tool_defs(executor, ask.is_some(), cfg.can_adjudicate_no_change);
1491    // ENVIRONMENT section (F7/L1): reuse the same cheap names-only repo summary
1492    // the contract-derivation prompt uses, so the coding loop and contract
1493    // derivation describe the repo identically. Local worktree by construction.
1494    let environment = super::rpc::summarize_repo(executor.worktree());
1495    // Information-flow labels for provenance classification of tool results.
1496    // Built once per run rather than per call: the map is static for the run,
1497    // and the classification happens on every tool result.
1498    let tool_labels = builtin_tool_labels();
1499    // What the repository says about how to work in it — its CLAUDE.md/AGENTS.md
1500    // and any `.car/` knowledge the team recorded. Until this landed the loop
1501    // saw only the two lines above, so review-time rules that no outcome
1502    // contract can express (car#1071) never reached the model at all.
1503    let project = super::project_context::project_context(executor.worktree());
1504    let system = system_prompt_with_overlay(
1505        contract,
1506        &environment,
1507        project.as_deref(),
1508        cfg.prompt_overlay.as_deref(),
1509    );
1510    let mut feedback: Option<String> = None;
1511    let mut last_results: Vec<CheckResult> = Vec::new();
1512    let mut consecutive_inference_failures = 0u32;
1513    // Whether this session already told the operator that its preferred
1514    // inference lane was skipped for want of a sign-in. Session-scoped and
1515    // announced ONCE: the point is to make a silent degrade visible, not to
1516    // repeat it on every turn of a long run (Parslee-ai/car#888).
1517    let mut announced_model_fallback = false;
1518    // The hop list written on the PREVIOUS turn, so an unchanged one is not
1519    // restated. Consecutive-only on purpose — see the loop below.
1520    let mut last_journaled_hops: Vec<(String, String, &'static str)> = Vec::new();
1521    // Whether the UNGATED auth path has already asked for a sign-in this run.
1522    // See the guard below for why only the ungated path is deduplicated.
1523    let mut announced_ungated_auth = false;
1524    // Consecutive repair iterations that ended in a no-progress thrash. The
1525    // conversation persists across rounds, so a genuinely wedged backbone
1526    // re-thrashes identically; abort on the second such iteration.
1527    let mut no_progress_iterations = 0u32;
1528    // The primary failing check from the previous red round + how many rounds it
1529    // has recurred, so the repair feedback escalates when the coder's edits keep
1530    // failing the SAME check (not converging on hard tasks).
1531    // Every failure signature seen so far this session, with how many rounds it
1532    // has appeared in. Session-scoped rather than consecutive so an oscillating
1533    // coder still trips the escalation.
1534    let mut seen_sigs: HashMap<String, u32> = HashMap::new();
1535    // This loop's clock starts here, before the first iteration.
1536
1537    // The signature of the failure carried into THIS iteration's repair, if
1538    // any. Drives skill recall (inject a prior fix) and outcome attribution
1539    // (credit/penalize that signature's skill once we know if the repair held).
1540    let mut prior_sig: Option<FailureSignature> = None;
1541
1542    // Persist ONE conversation across repair iterations (F2, audit 2026-07-06):
1543    // the model keeps the files it read, the edits it made, and the dead-ends it
1544    // already ruled out. A failed contract appends its feedback as a new user
1545    // turn on this thread rather than resetting to [System, User] every round —
1546    // so each repair builds on accumulated understanding instead of re-deriving
1547    // the repo from scratch. The thread is bounded to the model's context window
1548    // each turn (`compact_history_to_window` below) so a long repair session
1549    // can't overflow it.
1550    // Session-start recall (F8-lite/L3): a ONE-TIME, task-scoped heuristic lead
1551    // from prior sessions, computed once (one short lock) and injected into the
1552    // persistent first user turn. Distinct from the per-repair-round recall hint
1553    // below, which is failure-signature-scoped. `None` when learning is disabled
1554    // or nothing overlaps the intent → nothing is injected (no empty section).
1555    let mut initial_user = format!("Task:\n{intent}\n");
1556    if let Some(block) = memory.recall_for_task(intent).await {
1557        initial_user.push_str(
1558            "\nRecall from prior sessions (heuristic — verify against the repo \
1559             before acting on it):\n",
1560        );
1561        initial_user.push_str(&block);
1562    }
1563    let mut messages = vec![
1564        Message::System {
1565            content: system.clone(),
1566        },
1567        Message::User {
1568            content: initial_user,
1569        },
1570    ];
1571
1572    // The model's context window, resolved once (the model is fixed for the
1573    // run). Bounds the persistent conversation each turn; 0 (unknown — e.g. a
1574    // local/test generator) disables compaction, so behavior is unchanged there.
1575    let context_window = cfg
1576        .model
1577        .as_deref()
1578        .map(|m| inference.context_window(m))
1579        .unwrap_or(0);
1580    let adaptive_exclusions = if cfg.model.is_none() {
1581        cfg.exclude_models.clone()
1582    } else {
1583        Vec::new()
1584    };
1585    let strict_exclusions = !adaptive_exclusions.is_empty();
1586    // The budget this run's history is bounded to, named once at the top rather
1587    // than only implied by a compaction that may never fire. Same shared
1588    // fraction the assistant loop and the declarative-agent runner use; log
1589    // only — the loop's behavior is unchanged.
1590    tracing::debug!(
1591        context_window,
1592        budget = history_budget(context_window),
1593        "coder history compaction budget resolved for this run"
1594    );
1595
1596    for iteration in 1..=cfg.max_iterations {
1597        if cancel.load(Ordering::SeqCst) {
1598            return LoopOutcome::lost(
1599                LoopFailure::Cancelled,
1600                Some("cancelled".into()),
1601                iteration - 1,
1602                last_results,
1603            );
1604        }
1605        // Admission, not interruption: the previous iteration already evaluated
1606        // the contract, so a denial here cannot be hiding a green result — the
1607        // loop would have returned before asking.
1608        if let Some(reason) = cfg.deadline.admit() {
1609            sink.emit(CoderEventKind::BudgetExhausted {
1610                reason: reason.clone(),
1611                elapsed_secs: cfg.deadline.elapsed_secs(),
1612                iterations: iteration - 1,
1613            });
1614            return LoopOutcome::lost(
1615                LoopFailure::BudgetExhausted,
1616                Some(reason),
1617                iteration - 1,
1618                last_results,
1619            );
1620        }
1621        sink.emit(CoderEventKind::IterationStarted {
1622            n: iteration,
1623            max: cfg.max_iterations,
1624        });
1625
1626        // On a repair iteration, append the failing-check feedback (plus any
1627        // recalled prior-session fix for this failure signature) as a NEW user
1628        // turn on the persistent thread. Iteration 1 has no feedback — the task
1629        // seed above is the only user turn.
1630        if let Some(fb) = &feedback {
1631            let mut user = fb.clone();
1632            // Durable recall: if a prior session learned a fix for this failure
1633            // signature, inject it as a lead. No-op when learning is disabled or
1634            // nothing matches.
1635            if let Some(sig) = &prior_sig {
1636                if let Some(hint) = memory.recall(sig).await {
1637                    append_recall_hint(&mut user, &hint);
1638                }
1639            }
1640            messages.push(Message::User { content: user });
1641        }
1642
1643        // The model's closing summary for this iteration — captured to distill
1644        // a durable repair skill if this iteration turns the contract green.
1645        let mut closing_plan = String::new();
1646        // Inner tool-use conversation.
1647        let mut turn = 0;
1648        // No-progress guard (per iteration): counts identical READ-ONLY calls
1649        // since the last successful action, so a thrashing model (re-reading the
1650        // same thing with no edits) is caught. Cleared by a successful action
1651        // and reset per iteration. `no_progress_this_iteration` records a trip so
1652        // the turn loop breaks to contract evaluation.
1653        let mut identical_read_calls: std::collections::HashMap<(String, String), u32> =
1654            std::collections::HashMap::new();
1655        let mut identical_failed_edits: std::collections::HashMap<(String, String, String), u32> =
1656            std::collections::HashMap::new();
1657        let mut no_progress_this_iteration = false;
1658        // Journal the iteration's terminal decision for the harness miners: the
1659        // model declaring done (empty tool calls — possibly a truncated/starved
1660        // turn on a local model) vs. exhausting the per-iteration turn budget
1661        // (the coder-path "never finishes" signal). `last_model` attributes it.
1662        let mut last_model = String::new();
1663        // Every distinct model that served a turn in THIS iteration, in
1664        // first-seen order. `TurnCompleted` is a per-iteration terminal, so it
1665        // names only whichever model happened to finish — and an unpinned
1666        // session routes per request (`strict_model` is `cfg.model.is_some()`),
1667        // so a model can write turns 1-3 and be replaced before the terminal is
1668        // written. That left no record of it anywhere, which is how a model
1669        // could author most of a change and still pass the self-review gate
1670        // that refuses a reviewer who wrote what it is judging (car#1333).
1671        // EVERY exit from the turn loop owes a `record_turn_completed`. This is
1672        // carried on the stack and written out only by that call, so a `return`
1673        // added below without one silently drops the iteration's attribution —
1674        // which is how the gap this closes came to exist, the terminal already
1675        // being the only record.
1676        let mut models_this_iteration: Vec<String> = Vec::new();
1677        let mut model_declared_done = false;
1678        while turn < cfg.max_turns_per_iteration {
1679            turn += 1;
1680            if cancel.load(Ordering::SeqCst) {
1681                return LoopOutcome::lost(
1682                    LoopFailure::Cancelled,
1683                    Some("cancelled".into()),
1684                    iteration,
1685                    last_results,
1686                );
1687            }
1688
1689            apply_operator_guidance(cfg, &mut messages, sink);
1690            // Bound the persistent conversation to the model's context window
1691            // before each generate so a long repair session never overflows it —
1692            // an overflow head-truncates the System prompt (outcome contract) +
1693            // task provider-side on a small local model, the exact silent failure
1694            // this loop must avoid. Pins System + task, drops oldest middle turns
1695            // on a valid turn boundary; no-op when the window is unknown or fits.
1696            compact_history_to_window(&mut messages, context_window);
1697
1698            let mut req = GenerateRequest {
1699                prompt: intent.to_string(), // ignored when messages are set
1700                model: cfg.model.clone(),
1701                params: GenerateParams {
1702                    temperature: 0.0,
1703                    max_tokens: cfg.max_tokens_per_turn,
1704                    // A pinned backbone (e.g. `--model parslee/reasoning` for an
1705                    // A/B) must NOT silently degrade to a local model on a remote
1706                    // outage — that manufactures fake results. Fail loudly so the
1707                    // run is marked infra, not scored on the wrong model. Adaptive
1708                    // routing (model = None) keeps the resilient degrade behavior.
1709                    strict_model: cfg.model.is_some(),
1710                    ..Default::default()
1711                },
1712                tools: Some(tools.clone()),
1713                messages: Some(messages.clone()),
1714                intent: Some(car_inference::IntentHint {
1715                    task: Some(car_inference::TaskHint::Code),
1716                    // Stakes-aware routing: this loop edits and runs code in a
1717                    // real git worktree and can drive a merge — structurally
1718                    // irreversible, so it is UNCONDITIONALLY high-stakes (unlike
1719                    // a general planner, the coder can never be benign — its
1720                    // tools are write_file/run_command). Generate with the best
1721                    // model; cost is the wrong axis when a wrong edit lands in a
1722                    // real repo. No-op when `cfg.model` pins an explicit model
1723                    // (the router consults intent only on the unpinned arm).
1724                    high_stakes: true,
1725                    exclude_models: adaptive_exclusions.clone(),
1726                    strict_exclusions,
1727                    ..Default::default()
1728                }),
1729                ..Default::default()
1730            };
1731            maybe_apply_coder_proactive_memory(&mut req, intent, &messages, sink, memory).await;
1732
1733            // A native turn consumes the same absolute session budget as an
1734            // external round. Use the shared clock's remainder rather than
1735            // starting a per-turn clock: every fallback rung therefore spends
1736            // from one deadline, and a turn admitted near the end cannot run a
1737            // full model invocation past it.
1738            let result = match generate_native_turn(inference, req, &cfg.deadline, sink).await {
1739                Err(NativeTurnError::TimedOut) => {
1740                    let elapsed_secs = cfg.deadline.elapsed_secs();
1741                    let ceiling_secs = cfg.deadline.max_wall_secs().unwrap_or(elapsed_secs);
1742                    let reason = format!(
1743                        "native inference turn {turn} timed out after {elapsed_secs}s at the \
1744                         session's {ceiling_secs}s deadline"
1745                    );
1746                    // Error is the operator-facing in-flight failure. The
1747                    // budget event preserves the existing typed/durable cause
1748                    // used by finalization and scoring.
1749                    sink.emit(CoderEventKind::Error {
1750                        message: reason.clone(),
1751                    });
1752                    sink.emit(CoderEventKind::BudgetExhausted {
1753                        reason: reason.clone(),
1754                        elapsed_secs,
1755                        iterations: iteration,
1756                    });
1757                    sink.record_turn_completed(
1758                        "inference_timed_out",
1759                        Some("session_deadline"),
1760                        false,
1761                        turn,
1762                        &last_model,
1763                        &models_this_iteration,
1764                    );
1765                    // Dropping `generation` in `generate_native_turn` cancels worker-isolated
1766                    // inference: `inference_worker.rs` owns a kill-on-drop
1767                    // child guard. In-process fallback work runs on blocking
1768                    // threads and is not stopped; it may outlive this session.
1769                    // Either way the worktree, not inference, gets the verdict.
1770                    let results = evaluate_contract_with_baselines(
1771                        contract,
1772                        executor,
1773                        sink,
1774                        &cfg.baseline_captures,
1775                    )
1776                    .await;
1777                    return if results.iter().all(|result| result.passed) {
1778                        LoopOutcome::green(iteration, results)
1779                    } else {
1780                        LoopOutcome::lost(
1781                            LoopFailure::BudgetExhausted,
1782                            Some(reason),
1783                            iteration,
1784                            results,
1785                        )
1786                    };
1787                }
1788                Ok(r) => {
1789                    consecutive_inference_failures = 0;
1790                    // The call SUCCEEDED, but not on the lane the operator
1791                    // configured — that one's credential was rejected and the
1792                    // chain degraded to another model. Say so once, rather than
1793                    // let the run look healthy on a backbone nobody chose
1794                    // (Parslee-ai/car#888).
1795                    //
1796                    // The JOURNAL takes every transition; the stream is
1797                    // latched. A durable record of a run that degraded twice
1798                    // needs both facts, and the latch exists only so the live
1799                    // stream does not narrate every routing decision inside a
1800                    // phase (car#1351).
1801                    // One row per HOP, with an honest `to`: the next candidate
1802                    // the chain tried, or the model that finally served. A
1803                    // chain of 1 -> 2 -> 3 -> 4 is three transitions, not
1804                    // "1 -> 4".
1805                    //
1806                    // Deduped against the PREVIOUS turn only, not against
1807                    // everything seen. A persistent condition — a dead
1808                    // credential, a permanently-absent local model — sets the
1809                    // same hops on every turn, and 50 byte-identical rows for
1810                    // one transition is a tally, not a transition log. But a
1811                    // lane that rate-limits at turn 3, recovers, and
1812                    // rate-limits again at turn 20 is a NEW episode, and a
1813                    // session-wide set would suppress it — leaving a journal
1814                    // that cannot tell "changed once, early" from "flapped six
1815                    // times", which is the question this record exists to
1816                    // answer (linus review, car#1351).
1817                    let hops: Vec<(String, String, &'static str)> = r
1818                        .fallback_from
1819                        .iter()
1820                        .enumerate()
1821                        .map(|(i, fb)| {
1822                            let to = r
1823                                .fallback_from
1824                                .get(i + 1)
1825                                .map(|next| next.candidate.clone())
1826                                .unwrap_or_else(|| r.model_used.clone());
1827                            (fb.candidate.clone(), to, fallback_reason_label(fb.reason))
1828                        })
1829                        .collect();
1830                    if hops != last_journaled_hops {
1831                        for (from, to, reason) in &hops {
1832                            sink.record_model_fallback(from, to, reason);
1833                        }
1834                        last_journaled_hops = hops;
1835                    }
1836                    if let Some(lane) = r.auth_fallback_from.clone() {
1837                        if !announced_model_fallback {
1838                            announced_model_fallback = true;
1839                            sink.emit(CoderEventKind::ModelFallback {
1840                                from: lane,
1841                                to: r.model_used.clone(),
1842                                reason: MODEL_FALLBACK_REASON.into(),
1843                            });
1844                        }
1845                    }
1846                    r
1847                }
1848                Err(NativeTurnError::Generation(e)) => {
1849                    if let TurnGenerationError::NoEligibleModel { excluded_models } = &e {
1850                        // Strict exclusions are set by self-heal so a review
1851                        // seat cannot author the change it judges. Exhausting
1852                        // that set is configuration, not a failure of this
1853                        // backlog item, and retrying it three times would still
1854                        // invoke no provider. Return on the first turn with a
1855                        // typed cause; heal_tick uses that cause to avoid
1856                        // writing item backoff or a public failure comment.
1857                        let message = format!(
1858                            "no independent coder model is available: review models {} are excluded; \
1859                             configure an independent coder_model or review_models in heal.toml",
1860                            excluded_models
1861                        );
1862                        sink.emit(CoderEventKind::Error {
1863                            message: message.clone(),
1864                        });
1865                        sink.record_turn_completed(
1866                            "no_eligible_model",
1867                            None,
1868                            false,
1869                            turn,
1870                            &last_model,
1871                            &models_this_iteration,
1872                        );
1873                        let results = evaluate_contract_with_baselines(
1874                            contract,
1875                            executor,
1876                            sink,
1877                            &cfg.baseline_captures,
1878                        )
1879                        .await;
1880                        let passed = results.iter().all(|result| result.passed);
1881                        return if passed {
1882                            LoopOutcome::green(iteration, results)
1883                        } else {
1884                            LoopOutcome::lost(
1885                                LoopFailure::Configuration,
1886                                Some(message),
1887                                iteration,
1888                                results,
1889                            )
1890                        };
1891                    }
1892                    // Blocked on the HUMAN, not on the machinery. Ask and wait
1893                    // rather than burning a strike: retrying an expired
1894                    // credential three times in as many seconds cannot succeed,
1895                    // and the third strike throws away the worktree.
1896                    let message = e.to_string();
1897                    if is_auth_failure(&message) {
1898                        let gate = cfg.auth_gate.clone();
1899                        // The prompt is emitted whether or not this session can
1900                        // WAIT for the sign-in. Waiting needs a gate to poll,
1901                        // but "you need to sign in" is true either way — and the
1902                        // gate was wired only for a PINNED remote model
1903                        // (`rpc.rs` builds it from `session.model`), so on the
1904                        // default adaptive-routing path the operator got a
1905                        // generic inference failure and three strikes that
1906                        // discard the worktree, with nothing anywhere saying to
1907                        // sign in (Parslee-ai/car#888).
1908                        // ...but only ONCE on the ungated path, and EVERY time
1909                        // on the gated one. The two differ because only the
1910                        // gated path can resume: it waits, so a second lapse
1911                        // after a successful sign-in is a genuinely new event
1912                        // the operator has to act on again, and suppressing it
1913                        // would leave a resumed-then-lapsed-again run silent.
1914                        // The ungated path cannot resume — it falls straight
1915                        // through to strike counting — so three consecutive
1916                        // auth failures would write the same "sign in to
1917                        // continue" line into the transcript three times,
1918                        // saying nothing new after the first.
1919                        let announce = if gate.is_some() {
1920                            true
1921                        } else if announced_ungated_auth {
1922                            false
1923                        } else {
1924                            announced_ungated_auth = true;
1925                            true
1926                        };
1927                        if announce {
1928                            sink.emit(CoderEventKind::AuthRequired {
1929                                message: message.clone(),
1930                                // 0 = "not waiting" — an honest wire value, not
1931                                // a shortened wait.
1932                                wait_secs: gate.as_ref().map_or(0, |_| cfg.auth_wait.as_secs()),
1933                            });
1934                        }
1935                        // Deliberately no wait on the ungated path: with no gate
1936                        // to poll there is nothing that could observe a sign-in
1937                        // arriving, so waiting would just hang the run for the
1938                        // whole `auth_wait` window — worst of all for the user
1939                        // who never signed in and has no local model, who would
1940                        // trade a fast failure for a silent stall. Fall through
1941                        // to the ordinary strike counting instead.
1942                        if let Some(gate) = gate {
1943                            if wait_for_auth(gate.as_ref(), cfg.auth_wait, cancel, &cfg.deadline)
1944                                .await
1945                            {
1946                                // Recovered: this was never a failure of the
1947                                // work, so it must not count toward the strike
1948                                // budget either.
1949                                consecutive_inference_failures = 0;
1950                                continue;
1951                            }
1952                            // Journal the terminal BEFORE returning. This exit
1953                            // and the three-strike one below leave the turn loop
1954                            // without passing through either ordinary terminal,
1955                            // so every model that served this iteration was
1956                            // dropped with the stack frame — and a green here
1957                            // reaches `NeedsApproval` like any other. In
1958                            // iteration 1 that left the attribution empty and
1959                            // the self-review gate refused a change whose
1960                            // contract had passed; in iteration 2+ prior
1961                            // terminals made the set look complete while the
1962                            // model that wrote this iteration's edit was missing
1963                            // from it, which is car#1333 reached by another door.
1964                            sink.record_turn_completed(
1965                                "auth_lapsed",
1966                                None,
1967                                false,
1968                                turn,
1969                                &last_model,
1970                                &models_this_iteration,
1971                            );
1972                            let results = evaluate_contract_with_baselines(
1973                                contract,
1974                                executor,
1975                                sink,
1976                                &cfg.baseline_captures,
1977                            )
1978                            .await;
1979                            let passed = results.iter().all(|r| r.passed);
1980                            return if passed {
1981                                LoopOutcome::green(iteration, results)
1982                            } else {
1983                                LoopOutcome::lost(
1984                                    LoopFailure::NeedsAuth,
1985                                    Some(format!(
1986                                        "not signed in, and no credential appeared within {}s: {message}",
1987                                        cfg.auth_wait.as_secs()
1988                                    )),
1989                                    iteration,
1990                                    results,
1991                                )
1992                            };
1993                        }
1994                    }
1995                    consecutive_inference_failures += 1;
1996                    sink.emit(CoderEventKind::Error {
1997                        message: format!("inference failed (turn {turn}): {e}"),
1998                    });
1999                    if consecutive_inference_failures >= 3 {
2000                        // Same reasoning as the auth exit above: this returns
2001                        // without reaching either ordinary terminal, so the
2002                        // iteration's attribution has to be written here or it
2003                        // is lost. The comment below already says this path owes
2004                        // the ordinary one its bookkeeping; it paid memgine and
2005                        // skipped the journal.
2006                        sink.record_turn_completed(
2007                            "inference_failed",
2008                            None,
2009                            false,
2010                            turn,
2011                            &last_model,
2012                            &models_this_iteration,
2013                        );
2014                        // Ask the worktree before declaring a loss. The model
2015                        // may have landed every edit the contract wants and
2016                        // then had its backbone die; returning red without
2017                        // evaluating would let the inference transport
2018                        // pronounce a verdict it has no standing to give —
2019                        // the same defect `external_loop` carried until the
2020                        // classification moved after evaluation.
2021                        let results = evaluate_contract_with_baselines(
2022                            contract,
2023                            executor,
2024                            sink,
2025                            &cfg.baseline_captures,
2026                        )
2027                        .await;
2028                        let passed = results.iter().all(|r| r.passed);
2029                        // Same green, same credit. This is a second path that
2030                        // returns `passed: true`, so it owes memgine the skill
2031                        // record the ordinary green path makes below —
2032                        // otherwise a repair that held would be forgotten
2033                        // purely because the backbone died on the way out.
2034                        if passed {
2035                            if let Some(sig) = &prior_sig {
2036                                memory
2037                                    .record_success(sig, &winning_approach(sig, &closing_plan))
2038                                    .await;
2039                            }
2040                        }
2041                        // Branch rather than conditional fields: a run cannot
2042                        // both pass and carry a failure, and the constructors
2043                        // are what make that unrepresentable.
2044                        return if passed {
2045                            LoopOutcome::green(iteration, results)
2046                        } else {
2047                            // A credential failure asks for a person, not an
2048                            // infrastructure retry. This matters most when the
2049                            // inference error carries both causes: the remote
2050                            // login failed first and the local fallback then
2051                            // hit its memory ceiling. The auth cause wins so
2052                            // `car code-task` emits `config_error` instead of
2053                            // the retriable `infra_inference` class
2054                            // (Parslee-ai/car#1248).
2055                            let failure = if is_auth_failure(&message) {
2056                                LoopFailure::NeedsAuth
2057                            } else if matches!(
2058                                e.terminal_inference(),
2059                                Some((InferenceFailureKind::WorkspaceRequired, _))
2060                            ) {
2061                                // The same bucket the agent-builder path gives
2062                                // it (`coder/rpc.rs`). `Infrastructure` reads as
2063                                // "a retry might help", and no number of
2064                                // retries creates a workspace; `NeedsAuth` is
2065                                // wrong the other way, because the person is
2066                                // already signed in. One error, one answer,
2067                                // whichever coder surface asks.
2068                                LoopFailure::Configuration
2069                            } else {
2070                                LoopFailure::Infrastructure
2071                            };
2072                            LoopOutcome::lost(
2073                                failure,
2074                                Some(format!("inference failed repeatedly: {e}")),
2075                                iteration,
2076                                results,
2077                            )
2078                        };
2079                    }
2080                    continue; // retry the same turn
2081                }
2082            };
2083            // Guidance that arrived during inference supersedes the unused
2084            // proposal. No tool in this response has executed yet.
2085            if turn < cfg.max_turns_per_iteration
2086                && cfg
2087                    .steering
2088                    .as_ref()
2089                    .is_some_and(|inbox| inbox.has_pending())
2090            {
2091                continue;
2092            }
2093            last_model = result.model_used.clone();
2094            let served = result.model_used.trim();
2095            if !served.is_empty() && !models_this_iteration.iter().any(|m| m == served) {
2096                models_this_iteration.push(served.to_string());
2097            }
2098
2099            if result.tool_calls.is_empty() {
2100                // A turn that hit the max_tokens ceiling is CUT OFF, not a
2101                // completion — treating it as "done" silently accepts a
2102                // truncated answer (and, on a truncated tool call, would drop
2103                // the call entirely). Recognize the truncation and continue so
2104                // the model can finish where it left off. Bounded by
2105                // max_turns_per_iteration, so this cannot spin forever — and if
2106                // the model truncates every turn, the turn-budget terminal
2107                // below journals that exhaustion as the mineable failure.
2108                // (F3/F11, audit 2026-07-06.)
2109                if result.was_truncated() {
2110                    sink.emit(CoderEventKind::Error {
2111                        message: format!(
2112                            "model turn truncated (stop_reason={:?}) — continuing so it can finish",
2113                            result.stop_reason
2114                        ),
2115                    });
2116                    result.append_assistant_history(&mut messages, vec![]);
2117                    messages.push(Message::User {
2118                        content: "Your previous response was cut off at the token limit. \
2119                                  Continue exactly where you left off; if you were in the \
2120                                  middle of a tool call, re-issue that call in full."
2121                            .to_string(),
2122                    });
2123                    continue;
2124                }
2125                // Model says done — break to contract evaluation. Journal the
2126                // terminal so the finish is mineable; with the truncation guard
2127                // above, reaching here means a genuine (non-truncated) finish
2128                // (docs/audits/car-tracing-design-2026-07-07, native_loop:344).
2129                sink.record_turn_completed(
2130                    "empty_tool_calls",
2131                    result.stop_reason.as_deref(),
2132                    result.was_truncated(),
2133                    turn,
2134                    &result.model_used,
2135                    &models_this_iteration,
2136                );
2137                model_declared_done = true;
2138                if !result.text.trim().is_empty() {
2139                    closing_plan = result.text.clone();
2140                    sink.emit(CoderEventKind::PlanText {
2141                        text: result.text.clone(),
2142                    });
2143                }
2144                break;
2145            }
2146
2147            // Assign ids so ToolResult replies correlate (local models may
2148            // omit them), then execute sequentially in emitted order.
2149            let mut calls = result.tool_calls.clone();
2150            for (i, call) in calls.iter_mut().enumerate() {
2151                if call.id.is_none() {
2152                    call.id = Some(format!("call_{iteration}_{turn}_{i}"));
2153                }
2154            }
2155            result.append_assistant_history(&mut messages, calls.clone());
2156
2157            for call in &calls {
2158                let params = Value::Object(call.arguments.clone().into_iter().collect());
2159                sink.emit(CoderEventKind::ToolCall {
2160                    tool: call.name.clone(),
2161                    params_preview: preview(&params.to_string(), 400),
2162                });
2163                // A nomination leaves the loop immediately and unjudged. The
2164                // loop has neither the baseline results nor the contract's
2165                // provenance, and handing it those so it could decide for
2166                // itself is exactly the shape this design refuses — see
2167                // `super::no_change`. A malformed call is a plain tool error
2168                // the model can correct, not a terminal.
2169                if call.name == REPORT_NO_CHANGE_TOOL && cfg.can_adjudicate_no_change {
2170                    match parse_nomination(&params) {
2171                        Ok(nomination) => {
2172                            // Every exit from the turn loop owes a terminal
2173                            // record; without it the finding has no author, and
2174                            // self-heal's attribution gate misreads that as
2175                            // lost instrumentation.
2176                            sink.record_turn_completed(
2177                                "no_change_nominated",
2178                                result.stop_reason.as_deref(),
2179                                result.was_truncated(),
2180                                turn,
2181                                &result.model_used,
2182                                &models_this_iteration,
2183                            );
2184                            return LoopOutcome::reported(
2185                                nomination,
2186                                iteration,
2187                                last_results.clone(),
2188                            );
2189                        }
2190                        Err(message) => {
2191                            sink.emit(CoderEventKind::ToolResult {
2192                                tool: call.name.clone(),
2193                                ok: false,
2194                                preview: message.clone(),
2195                            });
2196                            messages.push(Message::ToolResult {
2197                                tool_use_id: call.id.clone().expect("assigned above"),
2198                                content: message,
2199                                provenance: Provenance::Internal,
2200                            });
2201                            continue;
2202                        }
2203                    }
2204                }
2205                // No-progress guard. A repeated read-only call (same tool+args)
2206                // with no editing/mutating call in between means the model is
2207                // thrashing — re-reading the same thing every turn without acting
2208                // on it (the signature of a backbone that never returns its tool
2209                // results). Successful actions (edit/write/shell/…) clear the
2210                // tracker below, so a legitimate read→edit→re-read or a
2211                // build→edit→build loop never trips. On a trip we DON'T fail the
2212                // session outright — we break to contract evaluation so earlier
2213                // edits that already satisfied the contract still pass; only a
2214                // second no-progress iteration (a genuinely wedged backbone, since
2215                // the conversation persists across repair rounds) aborts.
2216                if is_read_only_tool(&call.name) {
2217                    let c = identical_read_calls
2218                        .entry((call.name.clone(), params.to_string()))
2219                        .or_insert(0);
2220                    *c += 1;
2221                    if *c >= NO_PROGRESS_REPEAT_LIMIT && !no_progress_this_iteration {
2222                        no_progress_this_iteration = true;
2223                        sink.emit(CoderEventKind::Error {
2224                            message: format!(
2225                                "no-progress loop: `{}` called {c} times with identical arguments \
2226                                 and no intervening edit — ending this attempt",
2227                                call.name
2228                            ),
2229                        });
2230                    }
2231                }
2232                let (ok, content) = if call.name == ASK_USER_TOOL {
2233                    // Route to the user-input handler, not the worktree executor.
2234                    // The handler emits UserInputRequested, blocks on the gate
2235                    // (honoring cancel + timeout), and returns the user's text or
2236                    // an error the model can recover from.
2237                    match ask {
2238                        Some(asker) => {
2239                            let prompt = params
2240                                .get("prompt")
2241                                .and_then(Value::as_str)
2242                                .unwrap_or("")
2243                                .to_string();
2244                            match asker.ask(&prompt).await {
2245                                Ok(answer) => (true, answer),
2246                                Err(e) => (false, format!("ERROR: {e}")),
2247                            }
2248                        }
2249                        None => (
2250                            false,
2251                            "ERROR: ask_user is not available in this session".to_string(),
2252                        ),
2253                    }
2254                } else {
2255                    match executor.execute(&call.name, &params).await {
2256                        Ok(v) => (true, v.to_string()),
2257                        Err(e) => (false, format!("ERROR: {e}")),
2258                    }
2259                };
2260                if ok && !is_read_only_tool(&call.name) {
2261                    identical_read_calls.clear();
2262                    identical_failed_edits.clear();
2263                } else if !ok && matches!(call.name.as_str(), "edit_file" | "write_file") {
2264                    let count = identical_failed_edits
2265                        .entry((call.name.clone(), params.to_string(), content.clone()))
2266                        .or_insert(0);
2267                    *count += 1;
2268                    if *count >= NO_PROGRESS_REPEAT_LIMIT && !no_progress_this_iteration {
2269                        no_progress_this_iteration = true;
2270                        sink.emit(CoderEventKind::Error {
2271                            message: format!(
2272                                "no-progress loop: `{}` failed {count} times with identical arguments \
2273                                 and the same error, without a successful intervening action — \
2274                                 checking the current work before retrying",
2275                                call.name
2276                            ),
2277                        });
2278                    }
2279                }
2280                sink.emit(CoderEventKind::ToolResult {
2281                    tool: call.name.clone(),
2282                    ok,
2283                    preview: preview(&content, 400),
2284                });
2285                messages.push(Message::ToolResult {
2286                    tool_use_id: call.id.clone().expect("assigned above"),
2287                    content: preview(&content, 16 * 1024),
2288                    // Classified per call, from the same information-flow labels
2289                    // the exfiltration gate reads — never from a constant.
2290                    //
2291                    // This was `Provenance::Internal` unconditionally, justified
2292                    // by a comment reading "the coder's tools are local … none
2293                    // reach the network". That was true when it was written and
2294                    // is exactly the kind of premise that rots: car#1073 wants a
2295                    // network delegate, car#1069 a browser, and the moment either
2296                    // lands the constant is a lie that marks fetched pages as
2297                    // trusted. Deriving from labels means attaching such a tool
2298                    // classifies it correctly with no edit here — `http_request`,
2299                    // `web_search` and `browser` already carry `net_send` in
2300                    // `builtin_tool_labels`.
2301                    //
2302                    // An unlabeled tool reads Internal, which is the same blind
2303                    // spot the flow gate already has (`tool_output_is_external`
2304                    // documents why a name-matching guess would be worse). It is
2305                    // shared deliberately rather than papered over here.
2306                    provenance: if tool_output_is_external(&call.name, &tool_labels) {
2307                        Provenance::External
2308                    } else {
2309                        Provenance::Internal
2310                    },
2311                });
2312            }
2313            // A no-progress trip ends this attempt: stop taking turns and let the
2314            // contract decide (earlier edits may already have satisfied it).
2315            if no_progress_this_iteration {
2316                break;
2317            }
2318        }
2319        // Journal the inner loop's terminal (the contract, evaluated next, still
2320        // owns the pass/fail decision): a no-progress bail-out, or the "never
2321        // finishes" turn-budget exhaustion, vs. a genuine model-declared done.
2322        if no_progress_this_iteration {
2323            sink.record_turn_completed(
2324                "no_progress_loop",
2325                None,
2326                false,
2327                turn,
2328                &last_model,
2329                &models_this_iteration,
2330            );
2331        } else if !model_declared_done {
2332            sink.record_turn_completed(
2333                "max_turns",
2334                None,
2335                false,
2336                turn,
2337                &last_model,
2338                &models_this_iteration,
2339            );
2340        }
2341
2342        // Verify. The contract — not the model's self-report — decides.
2343        last_results =
2344            evaluate_contract_with_baselines(contract, executor, sink, &cfg.baseline_captures)
2345                .await;
2346        if last_results.iter().all(|r| r.passed) {
2347            // Durable learning: if THIS green came after a prior failure, the
2348            // repair held — credit (or ingest) the skill for that signature so
2349            // the next occurrence can recall the winning approach.
2350            if let Some(sig) = &prior_sig {
2351                memory
2352                    .record_success(sig, &winning_approach(sig, &closing_plan))
2353                    .await;
2354            }
2355            return LoopOutcome::green(iteration, last_results);
2356        }
2357        // Still red. If this iteration made no progress (reads or failed edits) and
2358        // the contract didn't pass, count it — a second consecutive no-progress
2359        // iteration means the backbone is wedged (it re-thrashes the persistent
2360        // conversation identically), so abort rather than burn every iteration.
2361        // A productive iteration resets the count.
2362        if no_progress_this_iteration {
2363            no_progress_iterations += 1;
2364            if no_progress_iterations >= 2 {
2365                // NOT `Infrastructure`, despite the error text's guess at a
2366                // backbone that drops tool results. A model that re-reads
2367                // without editing is just as plausibly a bad model, and the
2368                // guard cannot tell them apart. If this value ever reaches the
2369                // A/B denominator, calling it infra would launder a model
2370                // failure into an exclusion and inflate the pass rate — bias in
2371                // the direction that flatters us, which is the one to refuse.
2372                return LoopOutcome::lost(
2373                    LoopFailure::Verification,
2374                    Some(
2375                        "no-progress loop: repeated reads or identical failed edits made no progress \
2376                         across two attempts. Verification still fails; stopped before exhausting \
2377                         the iteration budget."
2378                            .to_string(),
2379                    ),
2380                    iteration,
2381                    last_results,
2382                );
2383            }
2384        } else {
2385            no_progress_iterations = 0;
2386        }
2387        // Track whether the SAME failure keeps recurring, so the repair feedback
2388        // escalates from "read the error" to "your approach isn't working — form
2389        // a different hypothesis" when the coder isn't converging. Keyed on the
2390        // signature, and counted across the whole session; see
2391        // [`failure_feedback`] for why neither the check name nor a consecutive
2392        // streak was the right key.
2393        //
2394        // A no-progress iteration is excluded, for the same reason the external
2395        // loop excludes a cut-short attempt: the model made zero edits, so the
2396        // signature is trivially identical to last round's and carries no
2397        // information about the hypothesis. Counting it would guarantee a
2398        // recurrence and then tell the model "your approach is NOT addressing
2399        // the real cause" when the true diagnosis is "you did not edit
2400        // anything" — a different situation with a different fix.
2401        let cur_sig = primary_failure(&last_results);
2402        let recurrences = if no_progress_this_iteration {
2403            0
2404        } else {
2405            record_recurrence(&mut seen_sigs, cur_sig.as_ref())
2406        };
2407
2408        // Record the failure against its signature (penalizing any recalled
2409        // approach that didn't hold) and carry it into the next repair round for
2410        // recall + attribution.
2411        feedback = Some(failure_feedback(&last_results, recurrences));
2412        if let Some(sig) = cur_sig {
2413            memory.record_failure(&sig).await;
2414            prior_sig = Some(sig);
2415        } else {
2416            prior_sig = None;
2417        }
2418    }
2419
2420    // Clean exhaustion of the iteration budget after real attempts: the
2421    // machinery worked, the hypotheses did not.
2422    LoopOutcome::lost(
2423        LoopFailure::Verification,
2424        None,
2425        cfg.max_iterations,
2426        last_results,
2427    )
2428}
2429
2430#[cfg(test)]
2431mod tests {
2432    #[tokio::test]
2433    async fn steering_during_inference_discards_the_unused_tool_proposal() {
2434        struct Guided {
2435            inbox: Arc<super::super::steering::SteeringInbox>,
2436            turns: AtomicUsize,
2437        }
2438        #[async_trait]
2439        impl TurnGenerator for Guided {
2440            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2441                match self.turns.fetch_add(1, Ordering::SeqCst) {
2442                    0 => {
2443                        self.inbox
2444                            .enqueue("Write right to x.txt instead.".into(), || Ok(()))
2445                            .unwrap();
2446                        Ok(turn(
2447                            "",
2448                            serde_json::json!([{"id":"stale", "name":"write_file", "arguments":{"path":"stale.txt", "content":"wrong"}}]),
2449                        ))
2450                    }
2451                    1 => {
2452                        assert!(req.messages.unwrap().iter().any(|m| matches!(m, Message::User { content } if content.contains("Write right to x.txt instead."))));
2453                        Ok(turn(
2454                            "",
2455                            serde_json::json!([{"id":"guided", "name":"write_file", "arguments":{"path":"x.txt", "content":"right"}}]),
2456                        ))
2457                    }
2458                    _ => Ok(turn("Done", serde_json::json!([]))),
2459                }
2460            }
2461        }
2462        let dir = tempfile::tempdir().unwrap();
2463        let executor = WorktreeExecutor::new(dir.path());
2464        let inbox = Arc::new(super::super::steering::SteeringInbox::default());
2465        let script = Guided {
2466            inbox: inbox.clone(),
2467            turns: AtomicUsize::new(0),
2468        };
2469        let (sink, events) = EventSink::collecting("guided");
2470        let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false));
2471        let contract = OutcomeContract {
2472            allow_credentials: false,
2473            description: "x.txt says right".into(),
2474            checks: vec![ContractCheck {
2475                name: "content".into(),
2476                command: crate::coder::test_cmds::contains_or_report("right", "x.txt"),
2477                expect_exit_zero: true,
2478                output_contains: None,
2479                timeout_secs: 10,
2480                baseline: false,
2481                differential: None,
2482            }],
2483        };
2484        let outcome = run_native_loop(
2485            &script,
2486            &executor,
2487            "Write a file",
2488            &contract,
2489            &sink,
2490            &cancel,
2491            &NativeLoopConfig {
2492                steering: Some(inbox.clone()),
2493                ..Default::default()
2494            },
2495            &RepairMemory::disabled(),
2496            None,
2497        )
2498        .await;
2499        assert!(outcome.passed, "{outcome:?}");
2500        assert!(!dir.path().join("stale.txt").exists());
2501        assert_eq!(
2502            std::fs::read_to_string(dir.path().join("x.txt")).unwrap(),
2503            "right"
2504        );
2505        assert!(!inbox.is_open());
2506        assert!(events.lock().unwrap().iter().any(|event| matches!(&event.kind, CoderEventKind::OperatorGuidance { status, .. } if status == "applied")));
2507    }
2508
2509    #[tokio::test(start_paused = true)]
2510    async fn native_wait_reports_retry_and_elapsed_without_changing_failure_kind() {
2511        struct Retrying;
2512        #[async_trait]
2513        impl TurnGenerator for Retrying {
2514            async fn generate(&self, _: GenerateRequest) -> Result<InferenceResult, String> {
2515                panic!("native generation must use the typed observed seam");
2516            }
2517            async fn generate_coder_observed(
2518                &self,
2519                _: GenerateRequest,
2520                observer: &mut (dyn FnMut(InferenceRetryProgress) + Send),
2521            ) -> Result<InferenceResult, TurnGenerationError> {
2522                observer(InferenceRetryProgress {
2523                    model: "fixture/model".into(),
2524                    attempt: 2,
2525                    reason: "transport",
2526                    backoff_ms: 500,
2527                });
2528                tokio::time::sleep(std::time::Duration::from_secs(16)).await;
2529                Err(TurnGenerationError::NonRetryableInference {
2530                    kind: InferenceFailureKind::ProviderAccount,
2531                    recovery: "account action needed".into(),
2532                })
2533            }
2534        }
2535        let (sink, events) = EventSink::collecting("progress-test");
2536        let deadline = SessionDeadline::shared_default();
2537        let result =
2538            generate_native_turn(&Retrying, GenerateRequest::default(), &deadline, &sink).await;
2539        assert!(matches!(
2540            result,
2541            Err(NativeTurnError::Generation(
2542                TurnGenerationError::NonRetryableInference {
2543                    kind: InferenceFailureKind::ProviderAccount,
2544                    ..
2545                }
2546            ))
2547        ));
2548        let events = events.lock().unwrap();
2549        assert!(matches!(
2550            &events[0].kind,
2551            CoderEventKind::InferenceRetry {
2552                attempt: 2,
2553                backoff_ms: 500,
2554                ..
2555            }
2556        ));
2557        assert!(matches!(
2558            &events[1].kind,
2559            CoderEventKind::InferenceWaiting { elapsed_secs: 15 }
2560        ));
2561        assert_eq!(
2562            events.len(),
2563            2,
2564            "progress must not imply a tool call or completion"
2565        );
2566    }
2567
2568    /// The journal label table lives in this crate; the wire spelling is
2569    /// serde's, in another. Two tables that must agree and cannot see each
2570    /// other drift silently — this is what makes that a compile-time-adjacent
2571    /// failure instead of a subtly wrong audit record.
2572    #[test]
2573    fn the_journal_labels_match_the_serde_spelling() {
2574        use car_inference::FallbackReason as R;
2575        for r in [
2576            R::CredentialRejected,
2577            R::CredentialAbsent,
2578            R::RateLimited,
2579            R::TimedOut,
2580            R::Failed,
2581        ] {
2582            let serde_spelling = serde_json::to_value(r).unwrap();
2583            assert_eq!(
2584                serde_spelling.as_str(),
2585                Some(fallback_reason_label(r)),
2586                "{r:?}"
2587            );
2588        }
2589    }
2590
2591    /// car#1071: `recall` is both advertised to the model AND dispatchable.
2592    ///
2593    /// These are separate facts since car#1078 made delegate dispatch
2594    /// default-closed. A tool in the prompt that the executor refuses is a
2595    /// worse failure than one that was never offered — the model spends a turn
2596    /// on it and gets `unknown tool`. The loop advertises and opens dispatch in
2597    /// the same place so the two sets cannot drift.
2598    #[test]
2599    fn recall_is_advertised_and_reachable_together() {
2600        let dir = tempfile::tempdir().unwrap();
2601        let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
2602
2603        // Attached, but closed until a run advertises it.
2604        assert!(
2605            !exec.delegates_reachable(),
2606            "delegates start closed — attachment is not reachability"
2607        );
2608        let memory_defs = exec.delegate_defs_named("recall");
2609        assert_eq!(memory_defs.len(), 1, "recall must be attached");
2610
2611        // The loop's own sequence.
2612        exec.advertise_delegates();
2613        assert!(exec.delegates_reachable(), "advertising must open dispatch");
2614
2615        let mut tools = native_loop_tool_defs(false);
2616        tools.extend(memory_defs);
2617        let names: Vec<String> = tools
2618            .iter()
2619            .filter_map(|d| d["name"].as_str().map(String::from))
2620            .collect();
2621        assert!(names.iter().any(|n| n == "recall"));
2622        assert!(
2623            names.iter().any(|n| n == "shell"),
2624            "built-ins still present"
2625        );
2626        // The Parslee document tools are attached but NOT advertised to a
2627        // coding run: advertising `recall` must not open the whole surface.
2628        assert!(
2629            !names.iter().any(|n| n.starts_with("parslee_")),
2630            "advertising recall must not offer the rest of the delegate surface"
2631        );
2632    }
2633
2634    /// Provenance is derived, not asserted — so a network tool attached later
2635    /// is classified correctly with no edit to the loop.
2636    ///
2637    /// This is the property, not the current behaviour. The loop hardcoded
2638    /// `Provenance::Internal` on the premise that "the coder's tools are local
2639    /// … none reach the network". car#1073 (network), car#1069 (browser) and
2640    /// car#1071 (memory) each falsify that premise, and a fetched page marked
2641    /// Internal is attacker-controlled text wearing a trusted label — worse
2642    /// given car#1081, where a triaged issue may come from a public tracker.
2643    #[test]
2644    fn tool_results_are_classified_from_labels_not_from_a_constant() {
2645        let labels = car_engine::builtin_tool_labels();
2646
2647        // Today's coder surface: local, and correctly Internal.
2648        for local in [
2649            "shell",
2650            "read_file",
2651            "write_file",
2652            "edit_file",
2653            "grep_files",
2654        ] {
2655            assert!(
2656                !car_engine::tool_output_is_external(local, &labels),
2657                "{local} is local and must not be marked External"
2658            );
2659        }
2660
2661        // The tools car#1073 and car#1069 attach. They are External because
2662        // they carry `net_send` in the same labels the exfiltration gate reads.
2663        // That is what makes attaching them safe without a second list to
2664        // remember.
2665        for networked in [
2666            "http_request",
2667            "web_search",
2668            "browse_navigate",
2669            "browse_observe",
2670            "browser_await_answer",
2671        ] {
2672            assert!(
2673                car_engine::tool_output_is_external(networked, &labels),
2674                "{networked} reaches the network and must be marked External"
2675            );
2676        }
2677
2678        // An unlabeled tool reads Internal. Stated so the blind spot is a
2679        // recorded decision rather than a surprise: the flow gate shares it,
2680        // and a name-matching guess would be wrong in both directions.
2681        assert!(!car_engine::tool_output_is_external(
2682            "some_unlabeled_tool",
2683            &labels
2684        ));
2685    }
2686
2687    /// A caller that cannot judge a nomination must not be offered the tool.
2688    /// Otherwise `LoopOutcome` comes back neither green nor failed and the
2689    /// caller silently discards the finding as an ordinary red.
2690    #[test]
2691    fn report_no_change_is_offered_only_to_a_caller_that_can_adjudicate() {
2692        // Built through the same seam the loop uses (#1063), so this cannot
2693        // pass against a hand-rolled list that has drifted from the real one.
2694        let names = |cfg: &NativeLoopConfig| -> Vec<String> {
2695            let mut tools = native_loop_tool_defs(false);
2696            if cfg.can_adjudicate_no_change {
2697                tools.push(report_no_change_tool_def());
2698            }
2699            tools
2700                .iter()
2701                .filter_map(|t| t["name"].as_str().map(String::from))
2702                .collect()
2703        };
2704
2705        let cannot = NativeLoopConfig::default();
2706        assert!(
2707            !cannot.can_adjudicate_no_change,
2708            "false is the only safe default"
2709        );
2710        assert!(!names(&cannot).contains(&REPORT_NO_CHANGE_TOOL.to_string()));
2711
2712        let can = NativeLoopConfig {
2713            can_adjudicate_no_change: true,
2714            ..Default::default()
2715        };
2716        assert!(names(&can).contains(&REPORT_NO_CHANGE_TOOL.to_string()));
2717    }
2718
2719    /// A mistyped `kind` must not fall back to `premise_wrong` — that is the one
2720    /// value which can terminate a session without a human.
2721    #[test]
2722    fn an_unknown_nomination_kind_is_rejected_not_defaulted() {
2723        let bad = serde_json::json!({
2724            "kind": "premise_wrongg",
2725            "summary": "s",
2726            "evidence": "e"
2727        });
2728        let err = parse_nomination(&bad).unwrap_err();
2729        assert!(err.contains("unknown kind"), "{err}");
2730
2731        for missing in ["kind", "summary", "evidence"] {
2732            let mut obj = serde_json::json!({
2733                "kind": "premise_wrong", "summary": "s", "evidence": "e"
2734            });
2735            obj.as_object_mut().unwrap().remove(missing);
2736            assert!(
2737                parse_nomination(&obj).is_err(),
2738                "`{missing}` must be required"
2739            );
2740        }
2741
2742        let ok = parse_nomination(&serde_json::json!({
2743            "kind": "non_code_decision", "summary": "s", "evidence": "e"
2744        }))
2745        .unwrap();
2746        assert_eq!(ok.kind, NoChangeKind::NonCodeDecision);
2747    }
2748
2749    use super::*;
2750    use crate::coder::contract::ContractCheck;
2751    use crate::coder::CoderEvent;
2752    use std::sync::atomic::AtomicUsize;
2753    use std::sync::Arc;
2754
2755    /// The credential summary car-inference folds in front of an exhaustion
2756    /// failure (`parslee_signed_out_route_failure` → `"{summary}; {hint}"`).
2757    const CREDENTIAL_SUMMARY: &str =
2758        "Parslee login is absent — run `car auth login` before retrying";
2759
2760    /// Every `InferenceError` variant, sorted. Paired with
2761    /// [`inference_error_variant`] so the classifier table below is provably
2762    /// complete rather than hand-selected.
2763    const ALL_INFERENCE_ERROR_VARIANTS: [&str; 20] = [
2764        "CatalogPreconditionMismatch",
2765        "ContentRefused",
2766        "ControlledTermination",
2767        "CredentialUnavailable",
2768        "DeadlineExceeded",
2769        "DeviceError",
2770        "DownloadFailed",
2771        "GatewayUnconfigured",
2772        "InferenceFailed",
2773        "Io",
2774        "LocalResourceBlocked",
2775        "ModelManagement",
2776        "ModelNotFound",
2777        "NoEligibleModel",
2778        "ProviderAccount",
2779        "ProviderKeyMissing",
2780        "TokenizationError",
2781        "Transient",
2782        "UnsupportedMode",
2783        "WorkspaceRequired",
2784    ];
2785
2786    /// Compile-time exhaustiveness guard: no wildcard, so adding a variant to
2787    /// `InferenceError` fails to compile here exactly as it does in the
2788    /// production `From<InferenceError>` match.
2789    fn inference_error_variant(error: &InferenceError) -> &'static str {
2790        match error {
2791            InferenceError::ModelNotFound(_) => "ModelNotFound",
2792            InferenceError::NoEligibleModel { .. } => "NoEligibleModel",
2793            InferenceError::DownloadFailed(_) => "DownloadFailed",
2794            InferenceError::InferenceFailed(_) => "InferenceFailed",
2795            InferenceError::CatalogPreconditionMismatch { .. } => "CatalogPreconditionMismatch",
2796            InferenceError::ControlledTermination => "ControlledTermination",
2797            InferenceError::ModelManagement(_) => "ModelManagement",
2798            InferenceError::LocalResourceBlocked { .. } => "LocalResourceBlocked",
2799            InferenceError::Transient { .. } => "Transient",
2800            InferenceError::DeadlineExceeded { .. } => "DeadlineExceeded",
2801            InferenceError::UnsupportedMode { .. } => "UnsupportedMode",
2802            InferenceError::ProviderAccount { .. } => "ProviderAccount",
2803            InferenceError::ProviderKeyMissing { .. } => "ProviderKeyMissing",
2804            InferenceError::CredentialUnavailable { .. } => "CredentialUnavailable",
2805            InferenceError::ContentRefused { .. } => "ContentRefused",
2806            InferenceError::GatewayUnconfigured { .. } => "GatewayUnconfigured",
2807            InferenceError::TokenizationError(_) => "TokenizationError",
2808            InferenceError::DeviceError(_) => "DeviceError",
2809            InferenceError::Io(_) => "Io",
2810            InferenceError::WorkspaceRequired { .. } => "WorkspaceRequired",
2811        }
2812    }
2813
2814    /// car-inference's own no-backend recovery text — the producer this crate
2815    /// classifies on. Built here, never copied, so wording drift in
2816    /// car-inference fails this test instead of silently re-enabling retries.
2817    fn real_no_backend_hint() -> String {
2818        car_inference::no_backend_recovery_hint(
2819            "model declares ModelSource::Delegated but no inference runner is registered",
2820        )
2821        .expect("car-inference emits a no-backend recovery hint for a missing runner")
2822    }
2823
2824    /// The builder's failure screen must read exactly like Chat's. Chat renders
2825    /// `InferenceError::NoEligibleModel` through that enum's Display ("after
2826    /// required exclusions"); `TurnGenerationError` keeps its own coder-facing
2827    /// Display ("after strict exclusions") for the heal runner, so the recovery
2828    /// text has to be rebuilt from the inference error. Equality, not
2829    /// containment — the two sentences differ by one word.
2830    #[test]
2831    fn coder_generation_renders_no_eligible_model_exactly_as_chat_does() {
2832        let excluded_models = "reviewer-a, reviewer-b";
2833        let (kind, recovery) = TurnGenerationError::NoEligibleModel {
2834            excluded_models: excluded_models.to_string(),
2835        }
2836        .terminal_inference()
2837        .expect("strict-exclusion exhaustion is terminal");
2838
2839        assert_eq!(kind, InferenceFailureKind::NoEligibleModel);
2840        assert_eq!(
2841            recovery,
2842            InferenceError::NoEligibleModel {
2843                excluded_models: excluded_models.to_string(),
2844            }
2845            .to_string()
2846        );
2847    }
2848
2849    /// The shape car-inference builds when a remote provider's configured key
2850    /// variables all resolve to nothing (`remote.rs`), not a hand-written
2851    /// stand-in: the message is the human-facing text the producer carries and
2852    /// the Display prefix is supplied by the variant itself.
2853    fn missing_provider_key() -> InferenceError {
2854        InferenceError::ProviderKeyMissing {
2855            provider: "openrouter".into(),
2856            model: "openrouter/auto".into(),
2857            env_vars: vec!["OPENROUTER_API_KEY".into()],
2858            message: "OpenRouter requires a key — run `car keys set openrouter` or connect \
2859                      your OpenRouter account in CarHost"
2860                .into(),
2861        }
2862    }
2863
2864    /// Chat renders a missing provider key through `InferenceError`'s own
2865    /// Display, which car#1567 kept byte-identical to the old `InferenceFailed`
2866    /// rendering. The builder's terminal screen must read that same sentence,
2867    /// so the recovery is the Display verbatim — equality, not containment, or
2868    /// a reworded prefix could drift the two screens apart unnoticed.
2869    #[test]
2870    fn coder_generation_renders_a_missing_provider_key_exactly_as_chat_does() {
2871        let error = missing_provider_key();
2872        let chat_text = error.to_string();
2873
2874        let (kind, recovery) = TurnGenerationError::from(error)
2875            .terminal_inference()
2876            .expect("a missing provider key cannot be repaired inside one build");
2877
2878        assert_eq!(kind, InferenceFailureKind::ProviderKeyMissing);
2879        assert_eq!(recovery, chat_text);
2880    }
2881
2882    #[test]
2883    fn coder_generation_classifies_only_non_retryable_inference_failures() {
2884        use car_inference::resource_policy::{
2885            LocalLoadPreflight, LocalLoadVerdict, ModelMemoryEstimate, ModelResourceEvidence,
2886        };
2887        use car_inference::CredentialFailure;
2888
2889        let preflight = LocalLoadPreflight {
2890            model_id: "local/test".into(),
2891            estimate: ModelMemoryEstimate {
2892                weights_mb: 1,
2893                runtime_overhead_mb: 0,
2894                context_overhead_mb: 0,
2895                transient_margin_mb: 0,
2896                estimated_peak_mb: 1,
2897                evidence: ModelResourceEvidence::CatalogExact,
2898            },
2899            configured_ceiling_mb: 1,
2900            resident_model_mb: 0,
2901            active_reservations_mb: 0,
2902            estimated_incremental_mb: 1,
2903            accelerator_total_mb: None,
2904            accelerator_resident_mb: None,
2905            accelerator_incremental_mb: None,
2906            live_available_mb: Some(0),
2907            emergency_reserve_mb: 1,
2908            verdict: LocalLoadVerdict::InsufficientLiveMemory,
2909        };
2910        let cases = [
2911            (
2912                InferenceError::LocalResourceBlocked {
2913                    preflight,
2914                    recovery: "choose a smaller model".into(),
2915                },
2916                InferenceFailureKind::LocalResourceBlocked,
2917                "choose a smaller model",
2918            ),
2919            (
2920                InferenceError::CredentialUnavailable {
2921                    provider: "parslee".into(),
2922                    model: "hosted/test".into(),
2923                    reason: CredentialFailure::SignedOut,
2924                    detail: "sign in".into(),
2925                },
2926                InferenceFailureKind::CredentialUnavailable,
2927                "sign in",
2928            ),
2929            (
2930                InferenceError::ProviderAccount {
2931                    provider: "provider".into(),
2932                    status: 401,
2933                    message: "rejected key".into(),
2934                },
2935                InferenceFailureKind::ProviderAccount,
2936                "rejected key",
2937            ),
2938            (
2939                missing_provider_key(),
2940                InferenceFailureKind::ProviderKeyMissing,
2941                "OpenRouter requires a key",
2942            ),
2943            (
2944                InferenceError::GatewayUnconfigured {
2945                    provider: "gateway".into(),
2946                    namespace: "managed".into(),
2947                    status: 503,
2948                    message: "configure an upstream".into(),
2949                },
2950                InferenceFailureKind::GatewayUnconfigured,
2951                "configure an upstream",
2952            ),
2953            (
2954                InferenceError::ModelNotFound("missing/model".into()),
2955                InferenceFailureKind::NoBackend,
2956                "missing/model",
2957            ),
2958            // The REAL producer output, not a hand-shortened literal: the hint
2959            // car-inference builds from NO_BACKEND_RECOVERY_MARKER.
2960            (
2961                InferenceError::InferenceFailed(real_no_backend_hint()),
2962                InferenceFailureKind::NoBackend,
2963                car_inference::NO_BACKEND_RECOVERY_MARKER,
2964            ),
2965            // The same producer output after apply_route_failure_context folded
2966            // a credential summary in front of it ("{summary}; {hint}"). This is
2967            // the shape a fresh, signed-out install actually produces, and a
2968            // starts_with classifier misses it.
2969            (
2970                InferenceError::InferenceFailed(format!(
2971                    "{CREDENTIAL_SUMMARY}; {}",
2972                    real_no_backend_hint()
2973                )),
2974                InferenceFailureKind::NoBackend,
2975                car_inference::NO_BACKEND_RECOVERY_MARKER,
2976            ),
2977            (
2978                InferenceError::NoEligibleModel {
2979                    excluded_models: "only/model".into(),
2980                },
2981                InferenceFailureKind::NoEligibleModel,
2982                "only/model",
2983            ),
2984            // Terminal, and terminal as CONFIGURATION: a retry cannot create a
2985            // workspace, and the recovery text must carry the web step rather
2986            // than a sign-in the person has already completed.
2987            (
2988                InferenceError::WorkspaceRequired {
2989                    provider: "Parslee".into(),
2990                    detail: "finish setting up at https://parslee.ai, then try again".into(),
2991                },
2992                InferenceFailureKind::WorkspaceRequired,
2993                "https://parslee.ai",
2994            ),
2995        ];
2996
2997        let mut covered: Vec<&'static str> = Vec::new();
2998        for (error, expected_kind, expected_text) in cases {
2999            covered.push(inference_error_variant(&error));
3000            let rendered = format!("{error}");
3001            let error = TurnGenerationError::from(error);
3002            let (kind, recovery) = error
3003                .terminal_inference()
3004                .unwrap_or_else(|| panic!("must be terminal: {rendered}"));
3005            assert_eq!(kind, expected_kind, "{rendered}");
3006            assert!(recovery.contains(expected_text), "{recovery}");
3007        }
3008
3009        // Every remaining variant, listed explicitly rather than swept up by a
3010        // wildcard: each one stays retryable inside a build, and the coverage
3011        // assertion below proves the list is complete.
3012        for retryable in [
3013            InferenceError::Transient {
3014                status: Some(503),
3015                message: "try later".into(),
3016            },
3017            InferenceError::CredentialUnavailable {
3018                provider: "parslee".into(),
3019                model: "hosted/test".into(),
3020                reason: CredentialFailure::RaceRetryable,
3021                detail: "credential appeared on re-read".into(),
3022            },
3023            InferenceError::InferenceFailed("temporary decoder failure".into()),
3024            InferenceError::DownloadFailed("mirror timed out".into()),
3025            InferenceError::CatalogPreconditionMismatch {
3026                detail: "revision moved".into(),
3027            },
3028            InferenceError::ControlledTermination,
3029            InferenceError::ModelManagement(
3030                car_inference::model_management::ModelManagementError::MissingReceipt {
3031                    model_id: "local/test".into(),
3032                },
3033            ),
3034            InferenceError::DeadlineExceeded {
3035                applied_ms: 1_000,
3036                elapsed_ms: 1_001,
3037                last_error: "still in flight".into(),
3038            },
3039            InferenceError::UnsupportedMode {
3040                mode: "video",
3041                backend: "mlx",
3042                reason: "not wired",
3043            },
3044            InferenceError::ContentRefused {
3045                provider: "provider".into(),
3046                kind: None,
3047                code: None,
3048                message: "refused on content grounds".into(),
3049            },
3050            InferenceError::TokenizationError("bad byte".into()),
3051            InferenceError::DeviceError("gpu hiccup".into()),
3052            InferenceError::Io(std::io::Error::other("socket reset")),
3053        ] {
3054            covered.push(inference_error_variant(&retryable));
3055            let rendered = format!("{retryable}");
3056            assert!(
3057                TurnGenerationError::from(retryable)
3058                    .terminal_inference()
3059                    .is_none(),
3060                "retryable failures keep the retry path: {rendered}"
3061            );
3062        }
3063
3064        covered.sort_unstable();
3065        covered.dedup();
3066        assert_eq!(
3067            covered, ALL_INFERENCE_ERROR_VARIANTS,
3068            "every InferenceError variant must appear in one of the two tables above"
3069        );
3070    }
3071
3072    fn contract_for_prompt_test() -> OutcomeContract {
3073        OutcomeContract {
3074            allow_credentials: false,
3075            description: "d".into(),
3076            checks: vec![],
3077        }
3078    }
3079
3080    /// A coding run shows the model the static built-ins, whatever delegate the
3081    /// executor happens to carry — which is what makes the daemon's `coder.*`
3082    /// loop and headless `car code-task` equivalent now that both build their
3083    /// executor with `for_coder_session` (Parslee-ai/car#1063). Routing this
3084    /// through `executor.all_tool_defs()` would add sign-in-gated
3085    /// A tool operator policy denies outright is not offered to the coding
3086    /// model. The inspector chain refuses it either way; this stops the loop
3087    /// spending a schema and a turn discovering that.
3088    ///
3089    /// Asserted as a set difference against the same executor with no denies,
3090    /// so it cannot pass by the tool simply being absent.
3091    #[test]
3092    fn the_coding_loop_withholds_tools_operator_policy_denies() {
3093        fn names(defs: &[Value]) -> std::collections::BTreeSet<String> {
3094            defs.iter()
3095                .filter_map(|d| d["name"].as_str().map(String::from))
3096                .collect()
3097        }
3098        let dir = tempfile::tempdir().unwrap();
3099
3100        let open = WorktreeExecutor::new(dir.path());
3101        let before = names(&coder_session_tool_defs(&open, false, false));
3102        assert!(
3103            before.contains("shell"),
3104            "control must offer the tool the next one denies: {before:?}"
3105        );
3106
3107        let denied = WorktreeExecutor::new(dir.path())
3108            .with_denied_tools(["shell".to_string()].into_iter().collect());
3109        let after = names(&coder_session_tool_defs(&denied, false, false));
3110
3111        assert_eq!(
3112            before.difference(&after).cloned().collect::<Vec<_>>(),
3113            vec!["shell".to_string()],
3114            "exactly the denied tool is withheld"
3115        );
3116        assert!(
3117            after.difference(&before).next().is_none(),
3118            "withholding must not ADD anything"
3119        );
3120    }
3121
3122    /// document-generation tools to every coding prompt, and fails here.
3123    #[test]
3124    fn the_coding_loop_advertises_the_built_ins_not_the_delegate() {
3125        fn names(defs: &[Value]) -> Vec<String> {
3126            defs.iter()
3127                .filter_map(|d| d["name"].as_str().map(String::from))
3128                .collect()
3129        }
3130
3131        let advertised = names(&native_loop_tool_defs(false));
3132        assert_eq!(advertised, names(&WorktreeExecutor::tool_defs()));
3133        assert!(
3134            !advertised.iter().any(|n| n.starts_with("parslee_")),
3135            "delegate tools leaked into the coding loop: {advertised:?}"
3136        );
3137        assert!(advertised.iter().any(|n| n == "shell"));
3138
3139        // The executor a real session gets does carry the delegate — the point
3140        // is that it does not change what the coding model is shown.
3141        let dir = tempfile::tempdir().unwrap();
3142        let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
3143        assert!(names(&exec.all_tool_defs())
3144            .iter()
3145            .any(|n| n.starts_with("parslee_")));
3146
3147        // `ask` is the one addition, and only when a handler is supplied.
3148        let with_ask = names(&native_loop_tool_defs(true));
3149        assert_eq!(with_ask.len(), advertised.len() + 1);
3150        assert_eq!(with_ask.last().unwrap(), ASK_USER_TOOL);
3151    }
3152
3153    /// The network pair is offered only when the operator granted the tier
3154    /// (car#1073). Both sides of the grant, without touching the process-global
3155    /// permission file — which is the reason `granted` is a parameter.
3156    #[test]
3157    fn the_network_pair_is_offered_only_once_the_operator_grants_it() {
3158        let dir = tempfile::tempdir().unwrap();
3159        let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
3160
3161        assert!(
3162            network_tool_defs(&exec, false).is_empty(),
3163            "an ungranted session must not be shown a tool the gate will refuse"
3164        );
3165
3166        let granted: Vec<String> = network_tool_defs(&exec, true)
3167            .iter()
3168            .filter_map(|d| d["name"].as_str().map(String::from))
3169            .collect();
3170        assert_eq!(granted, vec!["http_request", "web_search"]);
3171    }
3172
3173    /// Browser availability is a real per-session switch: omission leaves the
3174    /// surface absent, while opt-in advertises the same lazy assistant tools
3175    /// and opens delegate dispatch.
3176    #[test]
3177    fn final_policy_filter_withholds_browser_tools_after_opt_in_assembly() {
3178        let dir = tempfile::tempdir().unwrap();
3179        let policy_dir = dir.path().join(".car/policies");
3180        std::fs::create_dir_all(&policy_dir).unwrap();
3181        std::fs::write(
3182            policy_dir.join("browser.toml"),
3183            "deny_tool = [\"browse_navigate\"]\n",
3184        )
3185        .unwrap();
3186        let executor = WorktreeExecutor::for_coder_session(dir.path())
3187            .unwrap()
3188            .with_browser_tools();
3189        let tools = coder_session_tool_defs(&executor, false, false);
3190        assert!(!tools.iter().any(|tool| tool["name"] == "browse_navigate"));
3191        assert!(tools.iter().any(|tool| tool["name"] == "browse_observe"));
3192    }
3193
3194    #[test]
3195    fn the_coding_loop_offers_browser_tools_only_after_opt_in() {
3196        let dir = tempfile::tempdir().unwrap();
3197        let plain = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
3198        let plain_names: Vec<String> = coder_session_tool_defs(&plain, true, true)
3199            .iter()
3200            .filter_map(|d| d["name"].as_str().map(String::from))
3201            .collect();
3202        assert!(!plain_names.iter().any(|n| n.starts_with("browse_")));
3203        assert!(!plain_names.iter().any(|n| n.starts_with("browser_")));
3204
3205        let enabled = WorktreeExecutor::for_coder_session(dir.path())
3206            .unwrap()
3207            .with_browser_tools();
3208        assert!(!enabled.delegates_reachable());
3209        let enabled_names: Vec<String> = coder_session_tool_defs(&enabled, true, true)
3210            .iter()
3211            .filter_map(|d| d["name"].as_str().map(String::from))
3212            .collect();
3213        for required in [
3214            "browse_navigate",
3215            "browse_click",
3216            "browse_type",
3217            "browse_scroll",
3218            "browse_keypress",
3219            "browse_wait",
3220            "browse_observe",
3221            "browser_await_answer",
3222            "browser_await_signin",
3223            "browser_record_start",
3224            "browser_record_stop",
3225        ] {
3226            assert!(
3227                enabled_names.contains(&required.to_string()),
3228                "missing {required}"
3229            );
3230        }
3231        assert!(enabled.delegates_reachable());
3232    }
3233
3234    /// car#708: an overlay must reach the prompt, or the evolution loop still
3235    /// has nowhere to put a prompt change.
3236    #[test]
3237    fn an_overlay_is_appended_to_the_prompt() {
3238        let contract = contract_for_prompt_test();
3239        let base = system_prompt_with_overlay(&contract, "env", None, None);
3240        let with =
3241            system_prompt_with_overlay(&contract, "env", None, Some("Prefer smaller diffs."));
3242
3243        assert!(with.contains("Prefer smaller diffs."));
3244        assert!(
3245            with.starts_with(&base),
3246            "the overlay must be strictly additive — the base prompt has to survive verbatim"
3247        );
3248        assert!(with.len() > base.len());
3249    }
3250
3251    /// A repo carrying no instructions and no `.car/` must get exactly the
3252    /// prompt it got before this existed. Asserted rather than assumed: this is
3253    /// a change to every coder session's system prompt, and the no-op case is
3254    /// the one that must not regress.
3255    #[test]
3256    fn a_repo_with_no_project_context_gets_an_unchanged_prompt() {
3257        let contract = contract_for_prompt_test();
3258        let bare = system_prompt(&contract, "env", None);
3259        assert_eq!(system_prompt(&contract, "env", Some("")), bare);
3260        assert_eq!(system_prompt(&contract, "env", Some("  \n ")), bare);
3261    }
3262
3263    /// The instructions reach the model, and the framing that makes them usable
3264    /// travels with them.
3265    #[test]
3266    fn project_instructions_reach_the_system_prompt() {
3267        let contract = contract_for_prompt_test();
3268        // Held in scope rather than leaked with `keep()` — the directory only
3269        // needs to outlive the call.
3270        let dir = tempfile::tempdir().unwrap();
3271        std::fs::write(
3272            dir.path().join("CLAUDE.md"),
3273            "No cargo feature flags. Ever.",
3274        )
3275        .unwrap();
3276        let block = crate::coder::project_context::project_context(dir.path())
3277            .expect("a repo with CLAUDE.md yields a block");
3278
3279        let p = system_prompt(&contract, "env", Some(&block));
3280        assert!(p.contains("No cargo feature flags. Ever."));
3281        // The rule alone is not enough — a model that reads a convention and
3282        // then edits a check to satisfy it has made things worse.
3283        assert!(p.contains("does NOT check them"));
3284        assert!(p.contains("never weaken or edit a contract check"));
3285        // Ordering: constraints land after the environment, before how-to-work.
3286        let env_at = p.find("ENVIRONMENT:").expect("environment section");
3287        let rules_at = p.find("No cargo feature flags").expect("instructions");
3288        let how_at = p.find("How to work:").expect("how-to-work section");
3289        assert!(env_at < rules_at && rules_at < how_at);
3290    }
3291
3292    /// No overlay must be byte-identical to the prompt before overlays existed.
3293    #[test]
3294    fn no_overlay_changes_nothing() {
3295        let contract = contract_for_prompt_test();
3296        let base = system_prompt(&contract, "env", None);
3297        assert_eq!(
3298            system_prompt_with_overlay(&contract, "env", None, None),
3299            base
3300        );
3301        assert_eq!(
3302            system_prompt_with_overlay(&contract, "env", None, Some("")),
3303            base
3304        );
3305        assert_eq!(
3306            system_prompt_with_overlay(&contract, "env", None, Some("   \n ")),
3307            base,
3308            "whitespace is not an overlay"
3309        );
3310    }
3311
3312    /// The overlay is rendered last and explicitly subordinate, so a
3313    /// conflicting instruction reads as an addition rather than a replacement.
3314    /// This is the only thing standing between an evolved prompt and the rules
3315    /// the base prompt carries in prose.
3316    #[test]
3317    fn the_overlay_is_marked_subordinate_to_the_base_rules() {
3318        let contract = contract_for_prompt_test();
3319        let with = system_prompt_with_overlay(&contract, "env", None, Some("Commit when done."));
3320        let marker = with
3321            .find("ADDITIONAL GUIDANCE")
3322            .expect("the overlay must be delimited, not silently concatenated");
3323        assert!(
3324            with[marker..].contains("the rules above win"),
3325            "a conflicting overlay instruction must not read as authoritative"
3326        );
3327        assert!(
3328            with.find("Commit when done.").unwrap() > marker,
3329            "the overlay must come after its own header"
3330        );
3331    }
3332
3333    /// The overlay follows the harness config, and unlike the budgets it is not
3334    /// a max() — clearing it is how a rollback takes effect.
3335    #[test]
3336    fn merge_harness_adopts_and_clears_the_overlay() {
3337        let mut cfg = NativeLoopConfig::default();
3338        cfg.merge_harness(&car_memgine::HarnessConfig {
3339            prompt_overlay: Some("evolved guidance".into()),
3340            ..Default::default()
3341        });
3342        assert_eq!(cfg.prompt_overlay.as_deref(), Some("evolved guidance"));
3343
3344        cfg.merge_harness(&car_memgine::HarnessConfig {
3345            prompt_overlay: None,
3346            ..Default::default()
3347        });
3348        assert_eq!(
3349            cfg.prompt_overlay, None,
3350            "a rollback must actually remove the overlay, not leave it latched"
3351        );
3352    }
3353
3354    #[test]
3355    fn merge_harness_raises_coder_budgets_only_upward() {
3356        // A harness patch that raised planning_max_replans should raise the
3357        // coder's repair rounds; a lower/default knob must never lower them.
3358        let mut cfg = NativeLoopConfig {
3359            max_iterations: 8,
3360            max_turns_per_iteration: 24,
3361            ..Default::default()
3362        };
3363        cfg.merge_harness(&car_memgine::HarnessConfig {
3364            prompt_overlay: None,
3365            max_retries: 30,
3366            retry_backoff_ms: 0,
3367            planning_max_replans: 12, // > base 8 → raises to 13
3368        });
3369        assert_eq!(
3370            cfg.max_iterations, 13,
3371            "planning_max_replans+1 reaches the coder"
3372        );
3373        assert_eq!(
3374            cfg.max_turns_per_iteration, 30,
3375            "max_retries raises the turn floor"
3376        );
3377
3378        // A default (small) harness config never starves the coder below base.
3379        let mut base = NativeLoopConfig {
3380            max_iterations: 8,
3381            max_turns_per_iteration: 24,
3382            ..Default::default()
3383        };
3384        base.merge_harness(&car_memgine::HarnessConfig::default()); // {3,0,2}
3385        assert_eq!(base.max_iterations, 8, "never lowered below base");
3386        assert_eq!(base.max_turns_per_iteration, 24);
3387    }
3388
3389    /// Scripted generator: pops pre-canned turns in order.
3390    struct Script {
3391        turns: Vec<InferenceResult>,
3392        cursor: AtomicUsize,
3393        /// Every request the loop sent, in order. The repair feedback is only
3394        /// observable here — without it, nothing verifies that the escalation
3395        /// text is ever actually DELIVERED to the model, and a loop that passed
3396        /// a constant `0` would keep every helper test green.
3397        seen: std::sync::Mutex<Vec<GenerateRequest>>,
3398    }
3399
3400    impl Script {
3401        fn new(turns: Vec<InferenceResult>) -> Self {
3402            Self {
3403                turns,
3404                cursor: AtomicUsize::new(0),
3405                seen: std::sync::Mutex::new(Vec::new()),
3406            }
3407        }
3408        /// Concatenated text of every message in the n-th request.
3409        fn prompt(&self, n: usize) -> String {
3410            let reqs = self.seen.lock().expect("seen poisoned");
3411            serde_json::to_string(&reqs[n].messages).unwrap_or_default()
3412        }
3413        fn prompts(&self) -> usize {
3414            self.seen.lock().expect("seen poisoned").len()
3415        }
3416    }
3417
3418    fn turn(text: &str, tool_calls: serde_json::Value) -> InferenceResult {
3419        serde_json::from_value(serde_json::json!({
3420            "text": text,
3421            "tool_calls": tool_calls,
3422            "trace_id": "t",
3423            "model_used": "scripted",
3424            "latency_ms": 0,
3425        }))
3426        .expect("scripted InferenceResult shape")
3427    }
3428
3429    fn turn_with_stop(
3430        text: &str,
3431        tool_calls: serde_json::Value,
3432        stop_reason: Option<&str>,
3433    ) -> InferenceResult {
3434        serde_json::from_value(serde_json::json!({
3435            "text": text,
3436            "tool_calls": tool_calls,
3437            "trace_id": "t",
3438            "model_used": "scripted",
3439            "latency_ms": 0,
3440            "stop_reason": stop_reason,
3441        }))
3442        .expect("scripted InferenceResult shape")
3443    }
3444
3445    #[async_trait]
3446    impl TurnGenerator for Script {
3447        async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
3448            self.seen.lock().expect("seen poisoned").push(req);
3449            let i = self.cursor.fetch_add(1, Ordering::SeqCst);
3450            self.turns
3451                .get(i)
3452                .cloned()
3453                .ok_or_else(|| "script exhausted".to_string())
3454        }
3455    }
3456
3457    // --- LoopOutcome invariant --------------------------------------------
3458
3459    /// The invariant the constructors exist to enforce: `failure` is `None`
3460    /// exactly when `passed`. It held at all 24 literal sites by discipline
3461    /// alone, and discipline is not a mechanism.
3462    #[test]
3463    fn the_constructors_cannot_produce_a_passed_run_that_also_failed() {
3464        let green = LoopOutcome::green(3, Vec::new());
3465        assert!(green.passed);
3466        assert_eq!(green.failure, None);
3467        assert_eq!(green.error, None);
3468
3469        let lost = LoopOutcome::lost(LoopFailure::Verification, None, 3, Vec::new());
3470        assert!(!lost.passed);
3471        assert_eq!(lost.failure, Some(LoopFailure::Verification));
3472
3473        // A loss may carry explanatory text, and some of that text is a
3474        // cross-process contract (`car-cli`'s A/B scrapes it).
3475        let scraped = LoopOutcome::lost(
3476            LoopFailure::EngineUnavailable,
3477            Some("external agent 'codex' failed: no binary".into()),
3478            0,
3479            Vec::new(),
3480        );
3481        assert!(scraped.error.unwrap().starts_with("external agent '"));
3482    }
3483
3484    // --- Repair stagnation, keyed on the failure signature ----------------
3485
3486    fn failed(name: &str, exit: i64, tail: &str) -> CheckResult {
3487        CheckResult {
3488            credentials_allowed: false,
3489            name: name.into(),
3490            passed: false,
3491            exit_code: Some(exit),
3492            output_tail: tail.into(),
3493            duration_ms: 1,
3494            timed_out: false,
3495            deadline_clamped: false,
3496        }
3497    }
3498
3499    /// The false positive the signature key exists to remove. The check name
3500    /// never changes, but the error class does — the code went from not
3501    /// compiling to compiling-and-failing-a-test, which is progress. A
3502    /// name-keyed streak called that stagnation and told the model to abandon
3503    /// an approach that was working.
3504    #[test]
3505    fn a_changed_error_class_under_one_check_name_is_not_a_recurrence() {
3506        let mut seen = HashMap::new();
3507        let compile = primary_failure(&[failed("tests", 101, "error[E0433]: failed to resolve")]);
3508        let assertion = primary_failure(&[failed("tests", 1, "assertion `left == right` failed")]);
3509        assert_ne!(
3510            compile.as_ref().map(|s| s.key()),
3511            assertion.as_ref().map(|s| s.key()),
3512            "same check, different error class must be different signatures"
3513        );
3514        assert_eq!(record_recurrence(&mut seen, compile.as_ref()), 0);
3515        assert_eq!(
3516            record_recurrence(&mut seen, assertion.as_ref()),
3517            0,
3518            "progress must not read as a recurrence"
3519        );
3520    }
3521
3522    /// The identical failure twice IS a recurrence, and the count is what the
3523    /// escalated feedback states back to the model.
3524    #[test]
3525    fn the_identical_failure_recurs_and_counts_up() {
3526        let mut seen = HashMap::new();
3527        let sig = primary_failure(&[failed("tests", 1, "assertion `left == right` failed")]);
3528        assert_eq!(record_recurrence(&mut seen, sig.as_ref()), 0);
3529        assert_eq!(record_recurrence(&mut seen, sig.as_ref()), 1);
3530        assert_eq!(record_recurrence(&mut seen, sig.as_ref()), 2);
3531    }
3532
3533    /// The under-fire a consecutive-streak counter has: a coder alternating
3534    /// between two bad fixes resets a streak every round and never escalates,
3535    /// though it is exactly the non-convergence worth interrupting.
3536    #[test]
3537    fn an_oscillating_failure_still_recurs() {
3538        let mut seen = HashMap::new();
3539        let a = primary_failure(&[failed("tests", 1, "assertion `left == right` failed")]);
3540        let b = primary_failure(&[failed("build", 101, "error[E0433]: failed to resolve")]);
3541        assert_eq!(record_recurrence(&mut seen, a.as_ref()), 0);
3542        assert_eq!(record_recurrence(&mut seen, b.as_ref()), 0);
3543        assert_eq!(
3544            record_recurrence(&mut seen, a.as_ref()),
3545            1,
3546            "A -> B -> A is going in circles, not progress"
3547        );
3548    }
3549
3550    /// A green evaluation has no primary failure and must not be recorded.
3551    #[test]
3552    fn a_green_evaluation_is_not_a_recurrence() {
3553        let mut seen = HashMap::new();
3554        assert_eq!(record_recurrence(&mut seen, None), 0);
3555        assert!(seen.is_empty());
3556    }
3557
3558    /// The native loop's admission gate. Two loops implement this and only the
3559    /// external one was covered — and this is the one where an iteration has no
3560    /// wall bound of its own, so it is the one that can overrun furthest.
3561    #[tokio::test]
3562    async fn an_exhausted_budget_denies_admission_before_any_turn() {
3563        let script = Script::new(vec![turn("should never run", serde_json::json!([]))]);
3564        let dir = tempfile::tempdir().unwrap();
3565        let executor = WorktreeExecutor::new(dir.path());
3566        let sink = Arc::new(EventSink::test_sink());
3567        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3568        let cfg = NativeLoopConfig {
3569            // A deadline that is already spent.
3570            deadline: std::sync::Arc::new(SessionDeadline::new(Some(0))),
3571            ..Default::default()
3572        };
3573        let outcome = run_native_loop(
3574            &script,
3575            &executor,
3576            "x",
3577            &OutcomeContract {
3578                allow_credentials: false,
3579                description: "x".into(),
3580                checks: vec![ContractCheck {
3581                    name: "gate".into(),
3582                    command: "exit 1".into(),
3583                    expect_exit_zero: true,
3584                    output_contains: None,
3585                    timeout_secs: 10,
3586                    baseline: false,
3587                    differential: None,
3588                }],
3589            },
3590            &sink,
3591            &cancel,
3592            &cfg,
3593            &RepairMemory::disabled(),
3594            None,
3595        )
3596        .await;
3597        assert_eq!(
3598            script.prompts(),
3599            0,
3600            "the budget gates before any model turn"
3601        );
3602        assert_eq!(outcome.failure, Some(LoopFailure::BudgetExhausted));
3603        assert_eq!(outcome.iterations, 0);
3604        assert!(outcome
3605            .error
3606            .expect("the reason must surface")
3607            .contains("budget exhausted"));
3608    }
3609
3610    struct NeverReturns;
3611
3612    #[async_trait]
3613    impl TurnGenerator for NeverReturns {
3614        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
3615            std::future::pending().await
3616        }
3617    }
3618
3619    /// A native model turn is an in-flight part of the session budget, not an
3620    /// unbounded hole in it. The outer timeout is test scaffolding: on the old
3621    /// implementation it fires because `generate` is awaited bare. The inner
3622    /// deadline must win, leave a typed budget cause, and still run the outcome
3623    /// contract before returning.
3624    #[tokio::test]
3625    async fn a_native_turn_timeout_is_typed_and_still_evaluates_the_contract() {
3626        let dir = tempfile::tempdir().unwrap();
3627        let executor = WorktreeExecutor::new(dir.path());
3628        let (sink, events) = EventSink::collecting("native-turn-timeout");
3629        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3630        let cfg = NativeLoopConfig {
3631            deadline: std::sync::Arc::new(SessionDeadline::from_duration(Some(
3632                std::time::Duration::from_secs(1),
3633            ))),
3634            ..Default::default()
3635        };
3636        let contract = OutcomeContract {
3637            allow_credentials: false,
3638            description: "timeout still gets judged".into(),
3639            checks: vec![ContractCheck {
3640                name: "judged".into(),
3641                command: "exit 1".into(),
3642                expect_exit_zero: true,
3643                output_contains: None,
3644                timeout_secs: 10,
3645                baseline: false,
3646                differential: None,
3647            }],
3648        };
3649
3650        let outcome = tokio::time::timeout(
3651            std::time::Duration::from_secs(2),
3652            run_native_loop(
3653                &NeverReturns,
3654                &executor,
3655                "x",
3656                &contract,
3657                &sink,
3658                &cancel,
3659                &cfg,
3660                &RepairMemory::disabled(),
3661                None,
3662            ),
3663        )
3664        .await
3665        .expect("the native turn must stop at the session deadline");
3666
3667        assert_eq!(outcome.failure, Some(LoopFailure::BudgetExhausted));
3668        let events = events.lock().expect("events poisoned");
3669        assert!(events
3670            .iter()
3671            .any(|event| matches!(event.kind, CoderEventKind::Error { .. })));
3672        assert!(events.iter().any(|event| matches!(
3673            event.kind,
3674            CoderEventKind::CheckCompleted { ref result } if result.name == "judged"
3675        )));
3676    }
3677
3678    /// **The loop-level test.** Everything above exercises `record_recurrence`
3679    /// and `failure_feedback` in isolation, which would all still pass if
3680    /// `run_native_loop` threaded a constant `0` or if `seen_sigs` were moved
3681    /// inside the iteration loop and reset every round. This runs a real
3682    /// session against a stably-failing check and asserts the escalation
3683    /// reaches the model — in the round it should, and not before.
3684    #[tokio::test]
3685    async fn the_escalation_is_delivered_to_the_model_only_after_a_repeat() {
3686        let dir = tempfile::tempdir().unwrap();
3687        let executor = WorktreeExecutor::new(dir.path());
3688        let sink = Arc::new(EventSink::test_sink());
3689        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3690
3691        // Three iterations that each edit (so the no-progress guard stays out
3692        // of it) against a check that never goes green.
3693        let edit = |n: u32| {
3694            turn(
3695                "editing",
3696                serde_json::json!([{
3697                    "id": format!("c{n}"),
3698                    "name": "write_file",
3699                    "arguments": {"path": format!("f{n}.txt"), "content": "x"}
3700                }]),
3701            )
3702        };
3703        let script = Script::new(vec![
3704            edit(1),
3705            turn("done", serde_json::json!([])),
3706            edit(2),
3707            turn("done", serde_json::json!([])),
3708            edit(3),
3709            turn("done", serde_json::json!([])),
3710        ]);
3711        let contract = OutcomeContract {
3712            allow_credentials: false,
3713            description: "never green".into(),
3714            checks: vec![ContractCheck {
3715                name: "gate".into(),
3716                command: "exit 1".into(),
3717                expect_exit_zero: true,
3718                output_contains: None,
3719                timeout_secs: 10,
3720                baseline: false,
3721                differential: None,
3722            }],
3723        };
3724        let cfg = NativeLoopConfig {
3725            max_iterations: 3,
3726            ..Default::default()
3727        };
3728        let outcome = run_native_loop(
3729            &script,
3730            &executor,
3731            "x",
3732            &contract,
3733            &sink,
3734            &cancel,
3735            &cfg,
3736            &RepairMemory::disabled(),
3737            None,
3738        )
3739        .await;
3740        assert!(!outcome.passed);
3741
3742        // Iteration 1's prompt cannot contain it — nothing has repeated.
3743        assert!(
3744            !script.prompt(0).contains("failed the same way"),
3745            "escalated before anything repeated"
3746        );
3747        // By the last prompt the identical signature has recurred, so the
3748        // escalation must have been threaded through and delivered.
3749        let last = script.prompt(script.prompts() - 1);
3750        assert!(
3751            last.contains("failed the same way"),
3752            "the escalation never reached the model: {last}"
3753        );
3754    }
3755
3756    /// A generator that fails every turn, so the loop hits its
3757    /// three-consecutive-inference-failures bail-out.
3758    fn dead_backbone() -> Script {
3759        Script {
3760            turns: vec![],
3761            cursor: AtomicUsize::new(0),
3762            seen: std::sync::Mutex::new(Vec::new()),
3763        }
3764    }
3765
3766    async fn run_against(script: &Script, check: &str) -> LoopOutcome {
3767        let dir = tempfile::tempdir().unwrap();
3768        let executor = WorktreeExecutor::new(dir.path());
3769        let sink = Arc::new(EventSink::test_sink());
3770        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3771        let contract = OutcomeContract {
3772            allow_credentials: false,
3773            description: "x".into(),
3774            checks: vec![ContractCheck {
3775                name: "gate".into(),
3776                command: check.into(),
3777                expect_exit_zero: true,
3778                output_contains: None,
3779                timeout_secs: 10,
3780                baseline: false,
3781                differential: None,
3782            }],
3783        };
3784        run_native_loop(
3785            script,
3786            &executor,
3787            "x",
3788            &contract,
3789            &sink,
3790            &cancel,
3791            &NativeLoopConfig::default(),
3792            &RepairMemory::disabled(),
3793            None,
3794        )
3795        .await
3796    }
3797
3798    /// A backbone that reports "not signed in" for its first `n` calls, then
3799    /// behaves normally — the shape of a token lapsing mid-session.
3800    struct AuthFlaky {
3801        remaining: AtomicUsize,
3802        inner: Script,
3803    }
3804
3805    #[async_trait]
3806    impl TurnGenerator for AuthFlaky {
3807        async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
3808            if self.remaining.load(Ordering::SeqCst) > 0 {
3809                self.remaining.fetch_sub(1, Ordering::SeqCst);
3810                return Err("no credential for proprietary provider 'parslee': \
3811                            set $PARSLEE_ACCESS_TOKEN or run `car auth login parslee`"
3812                    .to_string());
3813            }
3814            self.inner.generate(req).await
3815        }
3816    }
3817
3818    /// Signs in on the first poll.
3819    #[derive(Debug)]
3820    struct SignsIn;
3821    #[async_trait]
3822    impl AuthGate for SignsIn {
3823        async fn is_authenticated(&self) -> bool {
3824            true
3825        }
3826    }
3827
3828    /// Nobody ever signs in.
3829    #[derive(Debug)]
3830    struct NeverSignsIn;
3831    #[async_trait]
3832    impl AuthGate for NeverSignsIn {
3833        async fn is_authenticated(&self) -> bool {
3834            false
3835        }
3836    }
3837
3838    async fn run_with_auth(
3839        gen: &dyn TurnGenerator,
3840        check: &str,
3841        gate: Arc<dyn AuthGate>,
3842        auth_wait: std::time::Duration,
3843    ) -> LoopOutcome {
3844        let dir = tempfile::tempdir().unwrap();
3845        let executor = WorktreeExecutor::new(dir.path());
3846        let sink = Arc::new(EventSink::test_sink());
3847        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3848        let contract = OutcomeContract {
3849            allow_credentials: false,
3850            description: "x".into(),
3851            checks: vec![ContractCheck {
3852                name: "gate".into(),
3853                command: check.into(),
3854                expect_exit_zero: true,
3855                output_contains: None,
3856                timeout_secs: 10,
3857                baseline: false,
3858                differential: None,
3859            }],
3860        };
3861        let cfg = NativeLoopConfig {
3862            auth_gate: Some(gate),
3863            auth_wait,
3864            ..Default::default()
3865        };
3866        run_native_loop(
3867            gen,
3868            &executor,
3869            "x",
3870            &contract,
3871            &sink,
3872            &cancel,
3873            &cfg,
3874            &RepairMemory::disabled(),
3875            None,
3876        )
3877        .await
3878    }
3879
3880    /// The point of the whole mechanism: a token that lapses mid-run must PAUSE
3881    /// the session, not end it. Previously three auth failures in as many
3882    /// seconds burned the strike budget and returned `Infrastructure`,
3883    /// discarding a worktree of real edits — a 29-minute session thrown away
3884    /// because a 15-minute token expired while the operator sat at the machine.
3885    #[tokio::test]
3886    async fn a_lapsed_credential_waits_for_sign_in_and_then_resumes() {
3887        let gen = AuthFlaky {
3888            // More than the 3-strike budget: if these counted as strikes the
3889            // run would be dead well before the script is reached.
3890            remaining: AtomicUsize::new(5),
3891            inner: Script::new(vec![turn("done", serde_json::json!([]))]),
3892        };
3893        let outcome = run_with_auth(
3894            &gen,
3895            "exit 0",
3896            Arc::new(SignsIn),
3897            std::time::Duration::from_secs(5),
3898        )
3899        .await;
3900
3901        assert!(
3902            outcome.passed,
3903            "the session must resume after sign-in, not die: {:?}",
3904            outcome.error
3905        );
3906        assert_eq!(outcome.failure, None);
3907    }
3908
3909    /// When nobody signs in, the run still ends — but as `NeedsAuth`, not
3910    /// `Infrastructure`. The distinction is the actionable part: one says "a
3911    /// person can fix this in seconds", the other says "the backbone is down".
3912    #[tokio::test]
3913    async fn nobody_signs_in_reports_needs_auth_not_infrastructure() {
3914        let gen = AuthFlaky {
3915            remaining: AtomicUsize::new(99),
3916            inner: Script::new(vec![turn("done", serde_json::json!([]))]),
3917        };
3918        let outcome = run_with_auth(
3919            &gen,
3920            "exit 1",
3921            Arc::new(NeverSignsIn),
3922            std::time::Duration::ZERO,
3923        )
3924        .await;
3925
3926        assert!(!outcome.passed);
3927        assert_eq!(
3928            outcome.failure,
3929            Some(LoopFailure::NeedsAuth),
3930            "an unanswered sign-in must not masquerade as an outage"
3931        );
3932    }
3933
3934    /// The credential error grew a detail suffix (car#797) naming WHICH failure
3935    /// it is — expired, unreadable, signed out. The historical prefix must
3936    /// survive that: this classifier keys on it as a substring, and so does the
3937    /// coder-ab harness's INFRA_MARKERS. Rewording the opening would silently
3938    /// reclassify auth failures as ordinary execution errors and take the
3939    /// wait-for-sign-in path down with them.
3940    #[test]
3941    fn enriched_credential_errors_still_classify_as_auth_failures() {
3942        for msg in [
3943            "no credential for proprietary provider 'parslee' (model parslee/reasoning): the \
3944             Parslee token expired at unix 1234 and could not be refreshed. Re-authenticate \
3945             with `car auth login`",
3946            "no credential for proprietary provider 'parslee' (model parslee/reasoning): the \
3947             credential store could not be read (code=152). This is not a sign-out",
3948            "no credential for proprietary provider 'parslee' (model parslee/reasoning): no \
3949             account is signed in. Run `car auth login`",
3950        ] {
3951            assert!(
3952                is_auth_failure(msg),
3953                "enriched credential error must still read as an auth failure: {msg}"
3954            );
3955        }
3956    }
3957
3958    /// A missing provider key is configuration, but it is not a Parslee sign-in
3959    /// failure. These are the exact strings preserved by
3960    /// `InferenceError::ProviderKeyMissing`; classifying either one here would
3961    /// park a pinned run on the Parslee-only auth gate, which cannot repair it.
3962    #[test]
3963    fn missing_provider_keys_do_not_enter_the_parslee_sign_in_wait() {
3964        for message in [
3965            "inference failed: OpenRouter requires a key — run `car keys set openrouter` or connect your OpenRouter account in CarHost",
3966            "inference failed: no API keys available for endpoint http://127.0.0.1:9/a47-missing-key (checked env vars: [\"CAR_TEST_A47_GENERIC_PROVIDER_KEY_DO_NOT_SET\", \"CAR_TEST_A47_GENERIC_EXTRA_KEY_1_DO_NOT_SET\", \"CAR_TEST_A47_GENERIC_EXTRA_KEY_2_DO_NOT_SET\"])",
3967        ] {
3968            assert!(!is_auth_failure(message), "wrong sign-in wait: {message}");
3969        }
3970    }
3971
3972    /// A workspace gap is not a sign-in failure, and the loop must prove it
3973    /// end to end, not just in the classifier.
3974    ///
3975    /// The gate here says the operator is ALREADY signed in — which is the
3976    /// truth in this scenario, and the trap: if `WorkspaceRequired` read as an
3977    /// auth failure, `wait_for_auth` would return true immediately, reset the
3978    /// strike count and re-drive the same doomed turn until the turn budget
3979    /// ran out, all while telling a signed-in person to sign in. Instead the
3980    /// session must end terminally, emit no `coder.auth_required`, and carry
3981    /// the remedy that actually applies.
3982    #[tokio::test]
3983    async fn a_workspace_gap_ends_the_session_without_a_sign_in_wait() {
3984        struct NoWorkspace {
3985            calls: AtomicUsize,
3986        }
3987        #[async_trait]
3988        impl TurnGenerator for NoWorkspace {
3989            async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
3990                panic!("native coder must use the typed generation seam")
3991            }
3992
3993            async fn generate_coder(
3994                &self,
3995                _req: GenerateRequest,
3996            ) -> Result<InferenceResult, TurnGenerationError> {
3997                self.calls.fetch_add(1, Ordering::SeqCst);
3998                Err(TurnGenerationError::from(
3999                    InferenceError::WorkspaceRequired {
4000                        provider: "Parslee".into(),
4001                        detail: "finish setting up at https://parslee.ai, then try again".into(),
4002                    },
4003                ))
4004            }
4005        }
4006
4007        let generator = NoWorkspace {
4008            calls: AtomicUsize::new(0),
4009        };
4010        let cfg = NativeLoopConfig {
4011            auth_gate: Some(Arc::new(SignsIn)),
4012            ..Default::default()
4013        };
4014        let (outcome, events) = run_collecting(&generator, "exit 1", cfg).await;
4015
4016        assert!(
4017            events
4018                .iter()
4019                .all(|event| !matches!(&event.kind, CoderEventKind::AuthRequired { .. })),
4020            "a signed-in operator must not be told to sign in"
4021        );
4022        assert_eq!(
4023            outcome.failure,
4024            Some(LoopFailure::Configuration),
4025            "the run ends as configuration — the same bucket `coder/rpc.rs` \
4026             gives this kind on the agent-builder path"
4027        );
4028        assert_ne!(
4029            outcome.failure,
4030            Some(LoopFailure::NeedsAuth),
4031            "the workspace step is not an auth repair"
4032        );
4033        assert_ne!(
4034            outcome.failure,
4035            Some(LoopFailure::Infrastructure),
4036            "no retry creates a workspace"
4037        );
4038        let error = outcome.error.expect("a terminal cause");
4039        assert!(error.contains("https://parslee.ai"), "{error}");
4040        // The ordinary strike budget, not an unbounded re-drive. A gate that
4041        // answers "authenticated" would otherwise clear the strikes forever.
4042        assert_eq!(
4043            generator.calls.load(Ordering::SeqCst),
4044            3,
4045            "the loop must stop on its own budget, not loop on a satisfied gate"
4046        );
4047    }
4048
4049    /// The classifier must not swallow a genuine outage: waiting for a human to
4050    /// repair a dead datacenter would hang a session that should fail.
4051    #[test]
4052    fn auth_failures_are_distinguished_from_outages() {
4053        assert!(is_auth_failure(
4054            "no credential for proprietary provider 'parslee': run `car auth login parslee`"
4055        ));
4056        assert!(is_auth_failure(
4057            "your Parslee session has expired or was rejected"
4058        ));
4059        assert!(is_auth_failure(
4060            "car-auth: cannot read Parslee credentials (secret store error)"
4061        ));
4062        assert!(is_auth_failure(
4063            "Parslee credential store unreadable for `parslee/reasoning`"
4064        ));
4065        assert!(is_auth_failure(
4066            "openai credential environment variable missing: `OPENAI_API_KEY` for explicitly requested `openai/gpt-5.6`"
4067        ));
4068        // The shape an EXPIRED (not missing) Parslee token actually takes.
4069        // Matched none of the hand-rolled substrings above, so it burned
4070        // inference strikes instead of pausing for sign-in — Parslee-ai/car#888.
4071        assert!(is_auth_failure(
4072            "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: \
4073             Authentication required"
4074        ));
4075
4076        assert!(!is_auth_failure("connection reset by peer"));
4077        assert!(!is_auth_failure("503 Service Unavailable"));
4078        assert!(!is_auth_failure("script exhausted"));
4079        assert!(!is_auth_failure("model failed, trying next fallback"));
4080    }
4081
4082    /// Fails every call with one fixed message. Unlike `dead_backbone`, the
4083    /// message is the test's — the classification under test keys on it.
4084    struct AlwaysFails {
4085        message: String,
4086    }
4087
4088    #[async_trait]
4089    impl TurnGenerator for AlwaysFails {
4090        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
4091            Err(self.message.clone())
4092        }
4093    }
4094
4095    /// Runs a loop with the DEFAULT config (so `auth_gate` is `None`) against a
4096    /// red check, collecting every event.
4097    async fn run_collecting(
4098        gen: &dyn TurnGenerator,
4099        check: &str,
4100        cfg: NativeLoopConfig,
4101    ) -> (LoopOutcome, Vec<CoderEvent>) {
4102        let dir = tempfile::tempdir().unwrap();
4103        let executor = WorktreeExecutor::new(dir.path());
4104        let (sink, collected) = EventSink::collecting("coder-auth");
4105        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4106        let contract = OutcomeContract {
4107            allow_credentials: false,
4108            description: "x".into(),
4109            checks: vec![ContractCheck {
4110                name: "gate".into(),
4111                command: check.into(),
4112                expect_exit_zero: true,
4113                output_contains: None,
4114                timeout_secs: 10,
4115                baseline: false,
4116                differential: None,
4117            }],
4118        };
4119        let outcome = run_native_loop(
4120            gen,
4121            &executor,
4122            "x",
4123            &contract,
4124            &sink,
4125            &cancel,
4126            &cfg,
4127            &RepairMemory::disabled(),
4128            None,
4129        )
4130        .await;
4131        let events = collected.lock().expect("collector poisoned").clone();
4132        (outcome, events)
4133    }
4134
4135    /// **The regression this whole change exists for.** `NativeLoopConfig`
4136    /// defaults `auth_gate` to `None`, and the daemon only builds a gate for an
4137    /// explicitly PINNED remote model — so on the default adaptive-routing path
4138    /// an expired token produced no `auth_required` at all: just three generic
4139    /// inference strikes and a discarded worktree, with nothing anywhere saying
4140    /// to sign in (Parslee-ai/car#888).
4141    ///
4142    /// The prompt must be emitted with no gate present, carrying `wait_secs: 0`
4143    /// because this path deliberately does NOT wait — with nothing to poll a
4144    /// sign-in against, waiting would only stall the run for the whole window —
4145    /// and exactly once, because a path that cannot resume has nothing new to
4146    /// say on the second and third strike.
4147    #[tokio::test]
4148    async fn an_ungated_auth_failure_still_asks_for_sign_in_without_waiting() {
4149        let gen = AlwaysFails {
4150            message: "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: \
4151                      Authentication required"
4152                .to_string(),
4153        };
4154        let (outcome, events) = run_collecting(&gen, "exit 1", NativeLoopConfig::default()).await;
4155
4156        let prompts: Vec<(u64, String)> = events
4157            .iter()
4158            .filter_map(|e| match &e.kind {
4159                CoderEventKind::AuthRequired { wait_secs, message } => {
4160                    Some((*wait_secs, message.clone()))
4161                }
4162                _ => None,
4163            })
4164            .collect();
4165        // Every one of the three strikes takes the auth branch, but the ungated
4166        // path cannot resume — so the second and third prompts would say
4167        // nothing the first did not. Exactly one, not merely "at least one".
4168        let strikes = events
4169            .iter()
4170            .filter(|e| {
4171                matches!(&e.kind, CoderEventKind::Error { message }
4172                    if message.contains("inference failed (turn"))
4173            })
4174            .count();
4175        assert_eq!(strikes, 3, "the run must have burned all three strikes");
4176        assert_eq!(
4177            prompts.len(),
4178            1,
4179            "an expired credential must ask for a sign-in exactly ONCE with no auth \
4180             gate — one prompt across all {strikes} strikes, not one per strike: {:?}",
4181            events.iter().map(|e| &e.kind).collect::<Vec<_>>()
4182        );
4183        for (wait_secs, message) in &prompts {
4184            assert_eq!(
4185                *wait_secs, 0,
4186                "an ungated loop is not waiting; saying it is would be a lie on the wire"
4187            );
4188            assert!(message.contains("401"), "the cause must survive: {message}");
4189        }
4190        // ...and the ungated path still falls through to ordinary strike
4191        // counting. A wait here would hang a user who never signed in and has
4192        // no local model for the entire `auth_wait` window. Once those strikes
4193        // run out, the typed terminal must still name auth rather than generic
4194        // inference infrastructure.
4195        assert_eq!(
4196            outcome.failure,
4197            Some(LoopFailure::NeedsAuth),
4198            "no gate means no wait, not that an expired login becomes infrastructure"
4199        );
4200    }
4201
4202    #[tokio::test]
4203    async fn mixed_auth_and_local_oom_is_classified_as_auth() {
4204        let gen = AlwaysFails {
4205            message: "inference failed: Parslee login expired for `parslee/reasoning` — \
4206                      run `car auth login`; fallback then failed: This model needs about \
4207                      9059 MB, beyond the configured 6553 MB local-model allocation"
4208                .to_string(),
4209        };
4210        let (outcome, events) = run_collecting(&gen, "exit 1", NativeLoopConfig::default()).await;
4211
4212        assert_eq!(outcome.failure, Some(LoopFailure::NeedsAuth));
4213        let auth_message = events
4214            .iter()
4215            .find_map(|event| match &event.kind {
4216                CoderEventKind::AuthRequired { message, .. } => Some(message.as_str()),
4217                _ => None,
4218            })
4219            .expect("mixed failure must emit auth_required");
4220        let auth_pos = auth_message
4221            .find("Parslee login expired")
4222            .expect("credential cause must be named");
4223        let oom_pos = auth_message
4224            .find("9059 MB")
4225            .expect("fallback OOM must remain as secondary detail");
4226        assert!(auth_pos < oom_pos, "credential cause must be named first");
4227    }
4228
4229    #[tokio::test]
4230    async fn genuine_local_oom_remains_inference_infrastructure() {
4231        let gen = AlwaysFails {
4232            message: "inference failed: This model needs about 9059 MB, beyond the configured \
4233                      6553 MB local-model allocation"
4234                .to_string(),
4235        };
4236        let (outcome, events) = run_collecting(&gen, "exit 1", NativeLoopConfig::default()).await;
4237
4238        assert_eq!(outcome.failure, Some(LoopFailure::Infrastructure));
4239        assert!(events
4240            .iter()
4241            .all(|event| !matches!(&event.kind, CoderEventKind::AuthRequired { .. })));
4242    }
4243
4244    /// Fails its first `remaining` calls with `message`, then serves `inner`.
4245    /// The sibling of `AuthFlaky` for the OTHER credential shape: a token that
4246    /// EXISTS but was REJECTED, which reaches the loop as a free-form inference
4247    /// error rather than car-auth's "no credential" wording, so the message has
4248    /// to be the test's (Parslee-ai/car#888).
4249    struct RejectedThenServes {
4250        remaining: AtomicUsize,
4251        message: String,
4252        inner: Script,
4253    }
4254
4255    #[async_trait]
4256    impl TurnGenerator for RejectedThenServes {
4257        async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
4258            if self.remaining.load(Ordering::SeqCst) > 0 {
4259                self.remaining.fetch_sub(1, Ordering::SeqCst);
4260                return Err(self.message.clone());
4261            }
4262            self.inner.generate(req).await
4263        }
4264    }
4265
4266    /// Signs in as soon as it is asked, and counts the asks — so a test can
4267    /// prove the loop actually took the WAIT path rather than falling through
4268    /// to strike counting. Mirrors `SignsIn`'s timing: `wait_for_auth` polls
4269    /// before it sleeps, so a gate that is already true costs no wall clock.
4270    #[derive(Debug, Default)]
4271    struct SignsInWhenAsked {
4272        polls: AtomicUsize,
4273    }
4274    #[async_trait]
4275    impl AuthGate for SignsInWhenAsked {
4276        async fn is_authenticated(&self) -> bool {
4277            self.polls.fetch_add(1, Ordering::SeqCst);
4278            true
4279        }
4280    }
4281
4282    /// End to end on the GATED path with the LITERAL error from
4283    /// Parslee-ai/car#888. `a_lapsed_credential_waits_for_sign_in_and_then_
4284    /// resumes` proves the mechanism, but drives it with one of the five
4285    /// original "no credential" substrings — so the composition this change is
4286    /// actually about (a real `HTTP 401 Unauthorized` + a wired gate → prompt
4287    /// with a real wait → resume, strikes reset) was only ever proven by
4288    /// reading the code.
4289    #[tokio::test]
4290    async fn a_rejected_credential_with_a_gate_waits_and_then_resumes() {
4291        let gate = Arc::new(SignsInWhenAsked::default());
4292        let cfg = NativeLoopConfig {
4293            auth_gate: Some(gate.clone()),
4294            // Non-zero, so `wait_secs` on the wire is a real window and this
4295            // test would catch the ungated `0` leaking onto the gated path.
4296            auth_wait: std::time::Duration::from_secs(5),
4297            ..Default::default()
4298        };
4299        let gen = RejectedThenServes {
4300            // One lapse, then the credential is good again.
4301            remaining: AtomicUsize::new(1),
4302            message: "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: \
4303                      Authentication required"
4304                .to_string(),
4305            inner: Script::new(vec![turn("done", serde_json::json!([]))]),
4306        };
4307        let (outcome, events) = run_collecting(&gen, "exit 0", cfg.clone()).await;
4308
4309        let prompts: Vec<u64> = events
4310            .iter()
4311            .filter_map(|e| match &e.kind {
4312                CoderEventKind::AuthRequired { wait_secs, .. } => Some(*wait_secs),
4313                _ => None,
4314            })
4315            .collect();
4316        assert_eq!(
4317            prompts,
4318            vec![cfg.auth_wait.as_secs()],
4319            "a gated lapse must advertise the REAL wait window, not 0: {:?}",
4320            events.iter().map(|e| &e.kind).collect::<Vec<_>>()
4321        );
4322        assert!(
4323            cfg.auth_wait.as_secs() > 0,
4324            "the window must be non-zero or the assertion above proves nothing"
4325        );
4326        assert!(
4327            gate.polls.load(Ordering::SeqCst) >= 1,
4328            "the loop must have actually waited on the gate"
4329        );
4330
4331        // Resumed rather than lost: the strike the lapse would otherwise have
4332        // burned was reset, so neither terminal auth verdict fires.
4333        assert!(
4334            outcome.passed,
4335            "the session must resume after sign-in, not die: {:?}",
4336            outcome.error
4337        );
4338        assert_eq!(
4339            outcome.failure, None,
4340            "a recovered lapse is neither NeedsAuth nor Infrastructure"
4341        );
4342    }
4343
4344    /// A run that keeps working on a fallback model because the preferred lane's
4345    /// credential was rejected must SAY so — once. Silence is the degrade this
4346    /// event exists to end; repeating it every turn is the noise the guard
4347    /// exists to prevent (Parslee-ai/car#888).
4348    #[tokio::test]
4349    async fn a_degraded_lane_is_announced_once_per_run() {
4350        let degraded = |text: &str, tool_calls: serde_json::Value| {
4351            let mut t = turn(text, tool_calls);
4352            t.auth_fallback_from = Some("parslee/reasoning".to_string());
4353            t.model_used = "local/qwen3".to_string();
4354            t
4355        };
4356        // Two SUCCESSFUL calls, both carrying the degrade: turn 1 edits, turn 2
4357        // declares done. One announcement must cover both.
4358        let script = Script::new(vec![
4359            degraded(
4360                "editing",
4361                serde_json::json!([{
4362                    "id": "c1",
4363                    "name": "write_file",
4364                    "arguments": {"path": "hello.txt", "content": "hello coder"}
4365                }]),
4366            ),
4367            degraded("done", serde_json::json!([])),
4368        ]);
4369        let (outcome, events) =
4370            run_collecting(&script, "exit 0", NativeLoopConfig::default()).await;
4371        assert!(outcome.passed, "outcome: {outcome:?}");
4372        assert_eq!(
4373            script.prompts(),
4374            2,
4375            "both degraded turns must actually have run"
4376        );
4377
4378        let announcements: Vec<(&str, &str)> = events
4379            .iter()
4380            .filter_map(|e| match &e.kind {
4381                CoderEventKind::ModelFallback { from, to, reason } => {
4382                    assert!(
4383                        reason.contains("car auth login"),
4384                        "the reason must name the remedy: {reason}"
4385                    );
4386                    Some((from.as_str(), to.as_str()))
4387                }
4388                _ => None,
4389            })
4390            .collect();
4391        assert_eq!(
4392            announcements,
4393            vec![("parslee/reasoning", "local/qwen3")],
4394            "exactly one announcement, naming the dead lane and the model that answered"
4395        );
4396    }
4397
4398    /// The native-side twin of `external_loop`'s transport regression: a dead
4399    /// backbone must not be able to fail a session whose worktree already
4400    /// satisfies the contract. This path used to return red without ever
4401    /// evaluating, letting the inference transport pronounce a verdict it has
4402    /// no standing to give.
4403    #[tokio::test]
4404    async fn a_dead_backbone_over_green_checks_still_passes() {
4405        let outcome = run_against(&dead_backbone(), &crate::coder::test_cmds::touch("m.txt")).await;
4406        assert!(outcome.passed, "the contract decides: {outcome:?}");
4407        assert_eq!(outcome.failure, None);
4408        assert!(outcome.error.is_none());
4409    }
4410
4411    /// The other direction: a dead backbone over red checks is still a loss,
4412    /// and still attributed to the machinery rather than the work.
4413    #[tokio::test]
4414    async fn a_dead_backbone_over_red_checks_is_infrastructure() {
4415        let outcome = run_against(&dead_backbone(), "exit 1").await;
4416        assert!(!outcome.passed);
4417        assert_eq!(outcome.failure, Some(LoopFailure::Infrastructure));
4418        assert!(outcome
4419            .error
4420            .expect("a dead backbone must surface")
4421            .contains("inference failed repeatedly"));
4422    }
4423
4424    #[tokio::test]
4425    async fn scripted_loop_edits_verifies_and_passes() {
4426        let dir = tempfile::tempdir().unwrap();
4427        let executor = WorktreeExecutor::new(dir.path());
4428        let (sink, collected) = EventSink::collecting("coder-native");
4429        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4430
4431        // Turn 1: write the file. Turn 2: declare done (no tool calls).
4432        let script = Script {
4433            turns: vec![
4434                turn(
4435                    "creating the file",
4436                    serde_json::json!([{
4437                        "id": "c1",
4438                        "name": "write_file",
4439                        "arguments": {"path": "hello.txt", "content": "hello coder"}
4440                    }]),
4441                ),
4442                turn("done — file created", serde_json::json!([])),
4443            ],
4444            cursor: AtomicUsize::new(0),
4445            seen: std::sync::Mutex::new(Vec::new()),
4446        };
4447        let contract = OutcomeContract {
4448            allow_credentials: false,
4449            description: "hello.txt exists with content".into(),
4450            checks: vec![ContractCheck {
4451                name: "exists".into(),
4452                command: crate::coder::test_cmds::contains("coder", "hello.txt"),
4453                expect_exit_zero: true,
4454                output_contains: None,
4455                timeout_secs: 10,
4456                baseline: false,
4457                differential: None,
4458            }],
4459        };
4460
4461        let outcome = run_native_loop(
4462            &script,
4463            &executor,
4464            "create hello.txt containing 'hello coder'",
4465            &contract,
4466            &sink,
4467            &cancel,
4468            &NativeLoopConfig::default(),
4469            &RepairMemory::disabled(),
4470            None,
4471        )
4472        .await;
4473
4474        assert!(outcome.passed, "outcome: {outcome:?}");
4475        assert_eq!(outcome.iterations, 1);
4476        assert!(dir.path().join("hello.txt").exists());
4477
4478        // Event stream shape: iteration → tool call/result → plan → check.
4479        let events = collected.lock().unwrap();
4480        let types: Vec<&str> = events
4481            .iter()
4482            .map(|e| match &e.kind {
4483                CoderEventKind::IterationStarted { .. } => "iteration",
4484                CoderEventKind::ToolCall { .. } => "tool_call",
4485                CoderEventKind::ToolResult { .. } => "tool_result",
4486                CoderEventKind::PlanText { .. } => "plan",
4487                CoderEventKind::CheckStarted { .. } => "check_started",
4488                CoderEventKind::CheckCompleted { .. } => "check_completed",
4489                _ => "other",
4490            })
4491            .collect();
4492        assert_eq!(
4493            types,
4494            vec![
4495                "iteration",
4496                "tool_call",
4497                "tool_result",
4498                "plan",
4499                "check_started",
4500                "check_completed"
4501            ]
4502        );
4503    }
4504
4505    /// Even a denied browser attempt leaves the ordinary coder call/result
4506    /// receipt pair. Policy runs before BrowserTools, so this also proves a
4507    /// denial does not launch Chromium as a side effect.
4508    #[tokio::test]
4509    async fn browser_policy_denial_is_recorded_as_tool_receipts() {
4510        let dir = tempfile::tempdir().unwrap();
4511        let policies = dir.path().join(".car").join("policies");
4512        std::fs::create_dir_all(&policies).unwrap();
4513        std::fs::write(
4514            policies.join("browser.toml"),
4515            "deny_tool = [\"browse_navigate\"]\n",
4516        )
4517        .unwrap();
4518        let executor = WorktreeExecutor::for_coder_session(dir.path())
4519            .unwrap()
4520            .with_browser_tools();
4521        let collected: Arc<std::sync::Mutex<Vec<crate::coder::CoderEvent>>> =
4522            Arc::new(std::sync::Mutex::new(Vec::new()));
4523        let collector = Arc::clone(&collected);
4524        let emitter: crate::coder::EventEmitter = Arc::new(move |event| {
4525            collector.lock().unwrap().push(event);
4526        });
4527        let journal = dir.path().join("browser-receipts.events.jsonl");
4528        let sink = EventSink::new("coder-browser", Some(emitter), Some(journal.clone()));
4529        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4530        let script = Script {
4531            turns: vec![
4532                turn(
4533                    "checking the page",
4534                    serde_json::json!([{
4535                        "id": "browser-call",
4536                        "name": "browse_navigate",
4537                        "arguments": {"url": "https://example.com"}
4538                    }]),
4539                ),
4540                turn("done", serde_json::json!([])),
4541            ],
4542            cursor: AtomicUsize::new(0),
4543            seen: std::sync::Mutex::new(Vec::new()),
4544        };
4545        let contract = OutcomeContract {
4546            allow_credentials: false,
4547            description: "receipt probe".into(),
4548            checks: vec![],
4549        };
4550
4551        let outcome = run_native_loop(
4552            &script,
4553            &executor,
4554            "inspect a page",
4555            &contract,
4556            &sink,
4557            &cancel,
4558            &NativeLoopConfig::default(),
4559            &RepairMemory::disabled(),
4560            None,
4561        )
4562        .await;
4563        assert!(outcome.passed, "outcome: {outcome:?}");
4564
4565        let events = collected.lock().unwrap();
4566        assert!(events.iter().any(|event| matches!(
4567            &event.kind,
4568            CoderEventKind::ToolCall { tool, .. } if tool == "browse_navigate"
4569        )));
4570        assert!(events.iter().any(|event| matches!(
4571            &event.kind,
4572            CoderEventKind::ToolResult { tool, ok: false, preview }
4573                if tool == "browse_navigate" && preview.contains("operator policy")
4574        )));
4575        drop(events);
4576        drop(sink);
4577
4578        let durable = car_eventlog::EventLog::load_read_only(&journal).unwrap();
4579        assert!(durable.events().iter().any(|event| {
4580            event.kind == car_eventlog::EventKind::ActionExecuting
4581                && event.action_id.as_deref() == Some("browse_navigate")
4582        }));
4583        assert!(durable.events().iter().any(|event| {
4584            event.kind == car_eventlog::EventKind::ActionFailed
4585                && event.action_id.as_deref() == Some("browse_navigate")
4586        }));
4587    }
4588
4589    // Identical read_file every turn — the thrash a backbone that drops tool
4590    // history induces (re-read forever, never edit, never declare done).
4591    fn identical_read_turn() -> InferenceResult {
4592        turn(
4593            "reading again",
4594            serde_json::json!([{
4595                "id": "c",
4596                "name": "read_file",
4597                "arguments": {"path": "src.py"}
4598            }]),
4599        )
4600    }
4601
4602    #[tokio::test]
4603    async fn native_loop_no_progress_bails_but_green_contract_still_passes() {
4604        // A no-progress trip must NOT fail a session whose earlier state already
4605        // satisfies the contract — the guard breaks to contract evaluation, and a
4606        // green contract wins. (Regression guard against reporting green as failed.)
4607        let dir = tempfile::tempdir().unwrap();
4608        let executor = WorktreeExecutor::new(dir.path());
4609        let (sink, _collected) = EventSink::collecting("coder-native");
4610        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4611
4612        let script = Script {
4613            turns: (0..NO_PROGRESS_REPEAT_LIMIT + 2)
4614                .map(|_| identical_read_turn())
4615                .collect(),
4616            cursor: AtomicUsize::new(0),
4617            seen: std::sync::Mutex::new(Vec::new()),
4618        };
4619        // Always-green contract.
4620        let contract = OutcomeContract {
4621            allow_credentials: false,
4622            description: "already satisfied".into(),
4623            checks: vec![ContractCheck {
4624                name: "ok".into(),
4625                // Portable `exit 0` — a bare `true` is a POSIX builtin and runs
4626                // through the coder's shell (`cmd /C` on Windows), where it is
4627                // not a command, so the "always-green" contract came back red.
4628                command: crate::coder::test_cmds::PASS.into(),
4629                expect_exit_zero: true,
4630                output_contains: None,
4631                timeout_secs: 10,
4632                baseline: false,
4633                differential: None,
4634            }],
4635        };
4636
4637        let outcome = run_native_loop(
4638            &script,
4639            &executor,
4640            "fix the bug",
4641            &contract,
4642            &sink,
4643            &cancel,
4644            &NativeLoopConfig::default(),
4645            &RepairMemory::disabled(),
4646            None,
4647        )
4648        .await;
4649
4650        assert!(
4651            outcome.passed,
4652            "green contract must pass despite the thrash: {outcome:?}"
4653        );
4654        assert_eq!(outcome.iterations, 1);
4655    }
4656
4657    #[tokio::test]
4658    async fn native_loop_repeated_failed_edits_verify_successful_work() {
4659        // A no-progress trip must NOT fail a session whose earlier state already
4660        // satisfies the contract — the guard breaks to contract evaluation, and a
4661        // green contract wins. (Regression guard against reporting green as failed.)
4662        let dir = tempfile::tempdir().unwrap();
4663        let executor = WorktreeExecutor::new(dir.path());
4664        let (sink, _collected) = EventSink::collecting("coder-native");
4665        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4666
4667        let mut turns = vec![
4668            turn(
4669                "",
4670                serde_json::json!([{"id":"write", "name":"write_file", "arguments":{"path":"result.txt", "content":"done"}}]),
4671            ),
4672            turn(
4673                "",
4674                serde_json::json!([{"id":"read", "name":"read_file", "arguments":{"path":"result.txt"}}]),
4675            ),
4676        ];
4677        turns.extend((0..NO_PROGRESS_REPEAT_LIMIT + 2).map(|_| turn("", serde_json::json!([
4678            {"id":"stale", "name":"edit_file", "arguments":{"path":"result.txt", "old_text":"missing", "new_text":"done"}}
4679        ]))));
4680        let script = Script {
4681            turns,
4682            cursor: AtomicUsize::new(0),
4683            seen: std::sync::Mutex::new(Vec::new()),
4684        };
4685        // Verify the file written earlier in this same attempt.
4686        let contract = OutcomeContract {
4687            allow_credentials: false,
4688            description: "already satisfied".into(),
4689            checks: vec![ContractCheck {
4690                name: "ok".into(),
4691                command: crate::coder::test_cmds::contains("done", "result.txt"),
4692                expect_exit_zero: true,
4693                output_contains: None,
4694                timeout_secs: 10,
4695                baseline: false,
4696                differential: None,
4697            }],
4698        };
4699
4700        let outcome = run_native_loop(
4701            &script,
4702            &executor,
4703            "fix the bug",
4704            &contract,
4705            &sink,
4706            &cancel,
4707            &NativeLoopConfig::default(),
4708            &RepairMemory::disabled(),
4709            None,
4710        )
4711        .await;
4712
4713        assert!(
4714            outcome.passed,
4715            "green contract must pass despite the thrash: {outcome:?}"
4716        );
4717        assert_eq!(outcome.iterations, 1);
4718        assert_eq!(
4719            script.cursor.load(Ordering::SeqCst),
4720            (NO_PROGRESS_REPEAT_LIMIT + 2) as usize
4721        );
4722        assert_eq!(
4723            std::fs::read_to_string(dir.path().join("result.txt")).unwrap(),
4724            "done"
4725        );
4726    }
4727
4728    #[tokio::test]
4729    async fn native_loop_aborts_after_two_no_progress_iterations() {
4730        // A genuinely wedged backbone re-thrashes the persistent conversation every
4731        // repair round; after the second no-progress iteration (contract still red)
4732        // the session aborts with a diagnostic instead of burning every iteration.
4733        let dir = tempfile::tempdir().unwrap();
4734        let executor = WorktreeExecutor::new(dir.path());
4735        let (sink, _collected) = EventSink::collecting("coder-native");
4736        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4737
4738        // Enough identical turns for two iterations to each trip the guard.
4739        let script = Script {
4740            turns: (0..(NO_PROGRESS_REPEAT_LIMIT * 2 + 4))
4741                .map(|_| identical_read_turn())
4742                .collect(),
4743            cursor: AtomicUsize::new(0),
4744            seen: std::sync::Mutex::new(Vec::new()),
4745        };
4746        // Always-red contract, so each no-progress iteration stays failed.
4747        let contract = OutcomeContract {
4748            allow_credentials: false,
4749            description: "never satisfied".into(),
4750            checks: vec![ContractCheck {
4751                name: "never".into(),
4752                command: crate::coder::test_cmds::FAIL.to_string(),
4753                expect_exit_zero: true,
4754                output_contains: None,
4755                timeout_secs: 10,
4756                baseline: false,
4757                differential: None,
4758            }],
4759        };
4760
4761        let outcome = run_native_loop(
4762            &script,
4763            &executor,
4764            "fix the bug",
4765            &contract,
4766            &sink,
4767            &cancel,
4768            &NativeLoopConfig::default(),
4769            &RepairMemory::disabled(),
4770            None,
4771        )
4772        .await;
4773
4774        assert!(!outcome.passed, "outcome: {outcome:?}");
4775        let err = outcome.error.unwrap_or_default();
4776        assert!(err.contains("no-progress loop"), "error was: {err}");
4777        // Aborted on the second iteration — not left to run all 8.
4778        assert_eq!(outcome.iterations, 2);
4779    }
4780
4781    /// A backbone that dies after the edit lands still names who wrote it.
4782    ///
4783    /// Three consecutive inference failures return `green` from INSIDE the turn
4784    /// loop when the contract passes — the model landed every edit and then lost
4785    /// its backbone. That exit reached neither ordinary terminal, so the whole
4786    /// iteration's attribution went with the stack frame. In iteration 1 the set
4787    /// came back empty and the self-review gate refused a change whose contract
4788    /// had passed; in iteration 2+ prior terminals made the set look complete
4789    /// while this iteration's author was missing from it, which is the very
4790    /// hole car#1333 is about.
4791    #[tokio::test]
4792    async fn a_dead_backbone_after_the_edit_still_records_the_author() {
4793        let dir = tempfile::tempdir().unwrap();
4794        let workspace = dir.path().join("workspace");
4795        std::fs::create_dir(&workspace).unwrap();
4796        let executor = WorktreeExecutor::new(&workspace);
4797        let journal = dir.path().join("journal").join("events.jsonl");
4798        let sink = EventSink::new("coder-dead-backbone", None, Some(journal.clone()));
4799        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4800
4801        // One turn writes the file the contract checks, then the script is
4802        // exhausted and every further `generate` errors — three strikes.
4803        let mut editing = turn(
4804            "creating the file",
4805            serde_json::json!([{
4806                "id": "c1",
4807                "name": "write_file",
4808                "arguments": {"path": "hello.txt", "content": "hello coder"}
4809            }]),
4810        );
4811        editing.model_used = "writer".to_string();
4812        let script = Script::new(vec![editing]);
4813
4814        let contract = OutcomeContract {
4815            allow_credentials: false,
4816            description: "hello.txt exists with content".into(),
4817            checks: vec![ContractCheck {
4818                name: "exists".into(),
4819                command: crate::coder::test_cmds::contains("coder", "hello.txt"),
4820                expect_exit_zero: true,
4821                output_contains: None,
4822                timeout_secs: 10,
4823                baseline: false,
4824                differential: None,
4825            }],
4826        };
4827
4828        let outcome = run_native_loop(
4829            &script,
4830            &executor,
4831            "create hello.txt containing 'hello coder'",
4832            &contract,
4833            &sink,
4834            &cancel,
4835            &NativeLoopConfig::default(),
4836            &RepairMemory::disabled(),
4837            None,
4838        )
4839        .await;
4840        // The worktree is asked before a loss is declared, so this is green.
4841        assert!(outcome.passed, "outcome: {outcome:?}");
4842
4843        drop(sink);
4844        let log = car_eventlog::EventLog::load(&journal).unwrap();
4845        let ev = log
4846            .events()
4847            .iter()
4848            .find(|e| e.kind == car_eventlog::EventKind::TurnCompleted)
4849            .expect("the backbone-death exit must journal a terminal too");
4850        assert_eq!(
4851            ev.data.get("decision"),
4852            Some(&serde_json::json!("inference_failed"))
4853        );
4854        assert_eq!(
4855            ev.data.get("models_served"),
4856            Some(&serde_json::json!(["writer"])),
4857            "the model that landed the edit must be named: {:?}",
4858            ev.data
4859        );
4860    }
4861
4862    /// A model that wrote turns inside an iteration another model finished is
4863    /// journaled too (car#1333).
4864    ///
4865    /// A `TurnCompleted` names only whichever model reached the terminal, so a
4866    /// chain that routed the editing turns to one model and the closing turn to
4867    /// another left the first with no record anywhere — and it could then sit on
4868    /// the self-review panel judging a change it had written.
4869    #[tokio::test]
4870    async fn native_loop_journals_every_model_that_served_an_iteration() {
4871        let dir = tempfile::tempdir().unwrap();
4872        let workspace = dir.path().join("workspace");
4873        std::fs::create_dir(&workspace).unwrap();
4874        let executor = WorktreeExecutor::new(&workspace);
4875        let journal = dir.path().join("journal").join("events.jsonl");
4876        let sink = EventSink::new("coder-models", None, Some(journal.clone()));
4877        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4878
4879        // `writer` does the edit; the chain then serves the closing turn from
4880        // `finisher`, which is the only one the terminal would name.
4881        let mut editing = turn(
4882            "creating the file",
4883            serde_json::json!([{
4884                "id": "c1",
4885                "name": "write_file",
4886                "arguments": {"path": "hello.txt", "content": "hello coder"}
4887            }]),
4888        );
4889        editing.model_used = "writer".to_string();
4890        let mut closing = turn("done — file created", serde_json::json!([]));
4891        closing.model_used = "finisher".to_string();
4892
4893        let script = Script::new(vec![editing, closing]);
4894        let contract = OutcomeContract {
4895            allow_credentials: false,
4896            description: "hello.txt exists with content".into(),
4897            checks: vec![ContractCheck {
4898                name: "exists".into(),
4899                command: crate::coder::test_cmds::contains("coder", "hello.txt"),
4900                expect_exit_zero: true,
4901                output_contains: None,
4902                timeout_secs: 10,
4903                baseline: false,
4904                differential: None,
4905            }],
4906        };
4907
4908        let outcome = run_native_loop(
4909            &script,
4910            &executor,
4911            "create hello.txt containing 'hello coder'",
4912            &contract,
4913            &sink,
4914            &cancel,
4915            &NativeLoopConfig::default(),
4916            &RepairMemory::disabled(),
4917            None,
4918        )
4919        .await;
4920        assert!(outcome.passed, "outcome: {outcome:?}");
4921
4922        drop(sink);
4923        let log = car_eventlog::EventLog::load(&journal).unwrap();
4924        let ev = log
4925            .events()
4926            .iter()
4927            .find(|e| e.kind == car_eventlog::EventKind::TurnCompleted)
4928            .expect("a terminal was journaled");
4929
4930        // The terminal still names who reached it…
4931        assert_eq!(
4932            ev.data.get("model_id"),
4933            Some(&serde_json::json!("finisher"))
4934        );
4935        // …and `models_served` names everyone, in first-seen order.
4936        assert_eq!(
4937            ev.data.get("models_served"),
4938            Some(&serde_json::json!(["writer", "finisher"])),
4939            "the model that wrote the edit must be recorded: {:?}",
4940            ev.data
4941        );
4942    }
4943
4944    #[tokio::test]
4945    async fn native_loop_empty_tool_calls_journals_turn_completed() {
4946        // The empty-tool-calls "model says done" terminal must record a durable
4947        // TurnCompleted so a truncated/starved local "done" is mineable as
4948        // false-success rather than passing silently as a clean finish.
4949        let dir = tempfile::tempdir().unwrap();
4950        let workspace = dir.path().join("workspace");
4951        std::fs::create_dir(&workspace).unwrap();
4952        let executor = WorktreeExecutor::new(&workspace);
4953        let journal = dir.path().join("journal").join("events.jsonl");
4954        // Keep the private journal root separate from the executor root. On
4955        // elevated Windows, hardening an ancestor after workspace files exist
4956        // can make those inherited entries unreadable to child processes.
4957        let sink = EventSink::new("coder-native", None, Some(journal.clone()));
4958        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4959
4960        let script = Script {
4961            turns: vec![
4962                turn(
4963                    "creating the file",
4964                    serde_json::json!([{
4965                        "id": "c1",
4966                        "name": "write_file",
4967                        "arguments": {"path": "hello.txt", "content": "hello coder"}
4968                    }]),
4969                ),
4970                turn("done — file created", serde_json::json!([])),
4971            ],
4972            cursor: AtomicUsize::new(0),
4973            seen: std::sync::Mutex::new(Vec::new()),
4974        };
4975        let contract = OutcomeContract {
4976            allow_credentials: false,
4977            description: "hello.txt exists with content".into(),
4978            checks: vec![ContractCheck {
4979                name: "exists".into(),
4980                command: crate::coder::test_cmds::contains("coder", "hello.txt"),
4981                expect_exit_zero: true,
4982                output_contains: None,
4983                timeout_secs: 10,
4984                baseline: false,
4985                differential: None,
4986            }],
4987        };
4988
4989        let outcome = run_native_loop(
4990            &script,
4991            &executor,
4992            "create hello.txt containing 'hello coder'",
4993            &contract,
4994            &sink,
4995            &cancel,
4996            &NativeLoopConfig::default(),
4997            &RepairMemory::disabled(),
4998            None,
4999        )
5000        .await;
5001        assert!(outcome.passed, "outcome: {outcome:?}");
5002
5003        // Drop the sink to join the journal writer thread (flush), then reload.
5004        drop(sink);
5005        let log = car_eventlog::EventLog::load(&journal).unwrap();
5006        let terminals: Vec<_> = log
5007            .events()
5008            .iter()
5009            .filter(|e| e.kind == car_eventlog::EventKind::TurnCompleted)
5010            .collect();
5011        assert_eq!(
5012            terminals.len(),
5013            1,
5014            "exactly one empty-tool-calls terminal recorded"
5015        );
5016        let ev = terminals[0];
5017        assert_eq!(
5018            ev.data.get("decision"),
5019            Some(&serde_json::json!("empty_tool_calls"))
5020        );
5021        assert_eq!(
5022            ev.data.get("model_id"),
5023            Some(&serde_json::json!("scripted"))
5024        );
5025        // "scripted" carries no provider prefix in the allow-list → unknown tier
5026        // (a real local run would surface e.g. "mlx/...": local).
5027        assert_eq!(
5028            ev.data.get("model_tier"),
5029            Some(&serde_json::json!("unknown"))
5030        );
5031    }
5032
5033    #[tokio::test]
5034    async fn native_loop_injects_proactive_memory_from_journaled_failures() {
5035        use car_memgine::MemgineEngine;
5036        use std::sync::Mutex as StdMutex;
5037        use tokio::sync::Mutex as AsyncMutex;
5038
5039        struct CaptureContext {
5040            seen: Arc<StdMutex<Vec<Option<String>>>>,
5041        }
5042
5043        #[async_trait]
5044        impl TurnGenerator for CaptureContext {
5045            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5046                self.seen.lock().unwrap().push(req.context.clone());
5047                Ok(turn("done", serde_json::json!([])))
5048            }
5049        }
5050
5051        let dir = tempfile::tempdir().unwrap();
5052        let executor = WorktreeExecutor::new(dir.path());
5053        let journal = dir.path().join("events.jsonl");
5054        let sink = EventSink::new("coder-native", None, Some(journal.clone()));
5055        sink.emit(CoderEventKind::ToolResult {
5056            tool: "shell".into(),
5057            ok: false,
5058            preview: "pytest failed because fixture data is missing".into(),
5059        });
5060        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5061        let memory = RepairMemory::new(Some(Arc::new(AsyncMutex::new(MemgineEngine::new(None)))));
5062        let seen = Arc::new(StdMutex::new(Vec::new()));
5063        let capture = CaptureContext { seen: seen.clone() };
5064        let contract = OutcomeContract {
5065            allow_credentials: false,
5066            description: "noop".into(),
5067            checks: vec![],
5068        };
5069
5070        let outcome = run_native_loop(
5071            &capture,
5072            &executor,
5073            "fix the pytest failure",
5074            &contract,
5075            &sink,
5076            &cancel,
5077            &NativeLoopConfig::default(),
5078            &memory,
5079            None,
5080        )
5081        .await;
5082
5083        assert!(outcome.passed, "outcome: {outcome:?}");
5084        let contexts = seen.lock().unwrap();
5085        let context = contexts[0].as_deref().unwrap_or("");
5086        assert!(
5087            context.contains("## Proactive Memory"),
5088            "request context should carry proactive memory: {context}"
5089        );
5090        assert!(
5091            context.contains("Action shell in proposal session failed"),
5092            "journaled failure should be injected: {context}"
5093        );
5094        drop(sink);
5095        let log = car_eventlog::EventLog::load(&journal).unwrap();
5096        assert!(log
5097            .events()
5098            .iter()
5099            .any(|e| e.kind == car_eventlog::EventKind::ProactiveMemoryMaintained));
5100        assert!(log.events().iter().any(|e| {
5101            e.kind == car_eventlog::EventKind::ProactiveMemoryIntervention
5102                && e.data.get("decision") == Some(&serde_json::json!("inject"))
5103        }));
5104    }
5105
5106    #[tokio::test]
5107    async fn native_loop_turn_budget_exhaustion_journals_max_turns() {
5108        // The model never declares done — it keeps issuing tool calls until the
5109        // per-iteration turn budget is exhausted. That "never finishes" terminal
5110        // must be journaled as a max_turns TurnCompleted (the coder path has no
5111        // stall guard, so this is the only signal that the budget burned out).
5112        let dir = tempfile::tempdir().unwrap();
5113        let executor = WorktreeExecutor::new(dir.path());
5114        let journal = dir.path().join("events.jsonl");
5115        let sink = EventSink::new("coder-native", None, Some(journal.clone()));
5116        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5117
5118        let tool_turn = || {
5119            turn(
5120                "still working",
5121                serde_json::json!([{
5122                    "id": "c",
5123                    "name": "write_file",
5124                    "arguments": {"path": "scratch.txt", "content": "x"}
5125                }]),
5126            )
5127        };
5128        let script = Script {
5129            turns: vec![tool_turn(), tool_turn()],
5130            cursor: AtomicUsize::new(0),
5131            seen: std::sync::Mutex::new(Vec::new()),
5132        };
5133        let contract = OutcomeContract {
5134            allow_credentials: false,
5135            description: "never satisfied".into(),
5136            checks: vec![ContractCheck {
5137                name: "exists".into(),
5138                command: crate::coder::test_cmds::contains("coder", "hello.txt"),
5139                expect_exit_zero: true,
5140                output_contains: None,
5141                timeout_secs: 10,
5142                baseline: false,
5143                differential: None,
5144            }],
5145        };
5146        let cfg = NativeLoopConfig {
5147            steering: None,
5148            prompt_overlay: None,
5149            model: None,
5150            exclude_models: Vec::new(),
5151            max_iterations: 1,
5152            max_turns_per_iteration: 2,
5153            max_tokens_per_turn: 4096,
5154            deadline: SessionDeadline::shared_default(),
5155            auth_gate: None,
5156            auth_wait: std::time::Duration::ZERO,
5157            can_adjudicate_no_change: false,
5158            baseline_captures: crate::coder::contract::BaselineCaptures::new(),
5159        };
5160
5161        let outcome = run_native_loop(
5162            &script,
5163            &executor,
5164            "keep writing forever",
5165            &contract,
5166            &sink,
5167            &cancel,
5168            &cfg,
5169            &RepairMemory::disabled(),
5170            None,
5171        )
5172        .await;
5173        assert!(!outcome.passed, "outcome: {outcome:?}");
5174
5175        drop(sink);
5176        let log = car_eventlog::EventLog::load(&journal).unwrap();
5177        let max_turns: Vec<_> = log
5178            .events()
5179            .iter()
5180            .filter(|e| {
5181                e.kind == car_eventlog::EventKind::TurnCompleted
5182                    && e.data.get("decision") == Some(&serde_json::json!("max_turns"))
5183            })
5184            .collect();
5185        assert_eq!(
5186            max_turns.len(),
5187            1,
5188            "turn-budget exhaustion recorded once as max_turns"
5189        );
5190        assert_eq!(max_turns[0].data.get("turns"), Some(&serde_json::json!(2)));
5191    }
5192
5193    #[tokio::test]
5194    async fn native_loop_compacts_persistent_history_to_context_window() {
5195        // F2 persists ONE conversation across repair turns; without bounding it to
5196        // the model's context window, a long run overflows and head-truncates the
5197        // System prompt (outcome contract) + task provider-side on a small local
5198        // model — the exact silent failure this loop must avoid. Assert the loop
5199        // calls compaction each turn: with a tiny window and large turns, the
5200        // history the generator sees stays bounded (does NOT accumulate one
5201        // exchange per turn) and always keeps the System prompt pinned at the head.
5202        use std::sync::Mutex;
5203
5204        struct RecordingGen {
5205            // (message count, starts-with-System) observed on each generate.
5206            seen: Arc<Mutex<Vec<(usize, bool)>>>,
5207            // Distinct call per turn so the (legitimate) no-progress guard, which
5208            // aborts on identical repeats, doesn't fire before all 12 turns run.
5209            turn_no: AtomicUsize,
5210        }
5211        #[async_trait]
5212        impl TurnGenerator for RecordingGen {
5213            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5214                let msgs = req.messages.as_ref().expect("coder always sets messages");
5215                let starts_with_system = matches!(msgs.first(), Some(Message::System { .. }));
5216                self.seen
5217                    .lock()
5218                    .unwrap()
5219                    .push((msgs.len(), starts_with_system));
5220                // A large assistant turn + a DISTINCT tool call every turn so the
5221                // persistent thread would grow unbounded absent compaction, and the
5222                // model never declares done (the contract below never passes).
5223                let n = self.turn_no.fetch_add(1, Ordering::SeqCst);
5224                Ok(turn(
5225                    &"x".repeat(8000),
5226                    serde_json::json!([{
5227                        "id": format!("c{n}"),
5228                        "name": "write_file",
5229                        "arguments": {"path": format!("big{n}.txt"), "content": "y"}
5230                    }]),
5231                ))
5232            }
5233            fn context_window(&self, _model: &str) -> usize {
5234                200 // tiny: budget = 150 tokens, far below one 8KB assistant turn
5235            }
5236        }
5237
5238        let dir = tempfile::tempdir().unwrap();
5239        let executor = WorktreeExecutor::new(dir.path());
5240        let (sink, _collected) = EventSink::collecting("compact-test");
5241        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5242        let seen: Arc<Mutex<Vec<(usize, bool)>>> = Arc::new(Mutex::new(Vec::new()));
5243        let gen = RecordingGen {
5244            seen: seen.clone(),
5245            turn_no: AtomicUsize::new(0),
5246        };
5247
5248        let cfg = NativeLoopConfig {
5249            steering: None,
5250            prompt_overlay: None,
5251            model: Some("scripted".into()),
5252            exclude_models: Vec::new(),
5253            max_iterations: 1,
5254            max_turns_per_iteration: 12,
5255            max_tokens_per_turn: 4096,
5256            deadline: SessionDeadline::shared_default(),
5257            auth_gate: None,
5258            auth_wait: std::time::Duration::ZERO,
5259            can_adjudicate_no_change: false,
5260            baseline_captures: crate::coder::contract::BaselineCaptures::new(),
5261        };
5262        let contract = OutcomeContract {
5263            allow_credentials: false,
5264            description: "never satisfied".into(),
5265            checks: vec![ContractCheck {
5266                name: "never".into(),
5267                command: crate::coder::test_cmds::FAIL.to_string(),
5268                expect_exit_zero: true,
5269                output_contains: None,
5270                timeout_secs: 10,
5271                baseline: false,
5272                differential: None,
5273            }],
5274        };
5275
5276        let _ = run_native_loop(
5277            &gen,
5278            &executor,
5279            "grow the thread",
5280            &contract,
5281            &sink,
5282            &cancel,
5283            &cfg,
5284            &RepairMemory::disabled(),
5285            None,
5286        )
5287        .await;
5288
5289        let seen = seen.lock().unwrap();
5290        assert_eq!(seen.len(), 12, "all 12 turns generated");
5291        // The System prompt (outcome contract) is pinned at the head EVERY turn —
5292        // compaction never drops it.
5293        assert!(
5294            seen.iter().all(|(_, sys)| *sys),
5295            "System prompt must stay pinned every turn"
5296        );
5297        // Bounded: absent compaction the 12th turn would see ~2 + 2*11 = 24
5298        // messages. Compaction caps it near the pinned head + recent tail.
5299        let max_len = seen.iter().map(|(n, _)| *n).max().unwrap();
5300        assert!(
5301            max_len < 14,
5302            "persistent history not bounded — max messages/turn = {max_len}"
5303        );
5304    }
5305
5306    #[tokio::test]
5307    async fn native_loop_routes_high_stakes_and_excludes_review_models() {
5308        // The coder loop edits and runs code in a real worktree, so EVERY
5309        // inference it issues must carry the high_stakes hint (quality-first) —
5310        // a wrong edit lands in a real repo. Capture every turn's intent (not
5311        // just the first) so the invariant survives a future refactor that might
5312        // hoist intent-setting out of the per-turn loop into a branch.
5313        use std::sync::Mutex;
5314        struct CapturingGen {
5315            intents: Arc<Mutex<Vec<Option<car_inference::IntentHint>>>>,
5316            cursor: AtomicUsize,
5317        }
5318        #[async_trait]
5319        impl TurnGenerator for CapturingGen {
5320            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5321                self.intents.lock().unwrap().push(req.intent.clone());
5322                // Turn 0 issues a tool call so the loop runs a SECOND turn; turn
5323                // 1 declares done so it then exits. Two captured intents prove
5324                // the hint rides every turn, not just the first.
5325                let i = self.cursor.fetch_add(1, Ordering::SeqCst);
5326                if i == 0 {
5327                    Ok(turn(
5328                        "",
5329                        serde_json::json!([{
5330                            "id": "c1", "name": "write_file",
5331                            "arguments": {"path": "f.txt", "content": "x"}
5332                        }]),
5333                    ))
5334                } else {
5335                    Ok(turn("done", serde_json::json!([])))
5336                }
5337            }
5338        }
5339
5340        let dir = tempfile::tempdir().unwrap();
5341        let executor = WorktreeExecutor::new(dir.path());
5342        let sink = EventSink::test_sink();
5343        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5344        let captured = Arc::new(Mutex::new(Vec::new()));
5345        let gen = CapturingGen {
5346            intents: captured.clone(),
5347            cursor: AtomicUsize::new(0),
5348        };
5349        let contract = OutcomeContract {
5350            allow_credentials: false,
5351            description: "noop".into(),
5352            checks: vec![],
5353        };
5354
5355        let _ = run_native_loop(
5356            &gen,
5357            &executor,
5358            "make a change",
5359            &contract,
5360            &sink,
5361            &cancel,
5362            &NativeLoopConfig {
5363                exclude_models: vec!["reviewer-a".into(), "reviewer-b".into()],
5364                ..Default::default()
5365            },
5366            &RepairMemory::disabled(),
5367            None,
5368        )
5369        .await;
5370
5371        {
5372            let intents = captured.lock().unwrap();
5373            assert!(
5374                intents.len() >= 2,
5375                "expected the loop to issue multiple inferences, got {}",
5376                intents.len()
5377            );
5378            for (n, intent) in intents.iter().enumerate() {
5379                let intent = intent
5380                    .as_ref()
5381                    .unwrap_or_else(|| panic!("turn {n} issued an inference with no IntentHint"));
5382                assert!(intent.high_stakes, "turn {n} must route high_stakes");
5383                assert_eq!(
5384                    intent.task,
5385                    Some(car_inference::TaskHint::Code),
5386                    "turn {n} must keep the Code task hint"
5387                );
5388                assert_eq!(
5389                    intent.exclude_models,
5390                    ["reviewer-a", "reviewer-b"],
5391                    "turn {n} must exclude every review-panel seat"
5392                );
5393                assert!(
5394                    intent.strict_exclusions,
5395                    "turn {n} must refuse instead of falling back to a review-panel seat"
5396                );
5397            }
5398        }
5399
5400        // An explicit coder model is an operator decision, not an adaptive
5401        // candidate. Keep the configured panel list out of its IntentHint so
5402        // the router never silently substitutes around that pin.
5403        let pinned_captured = Arc::new(Mutex::new(Vec::new()));
5404        let pinned = CapturingGen {
5405            intents: pinned_captured.clone(),
5406            cursor: AtomicUsize::new(0),
5407        };
5408        let _ = run_native_loop(
5409            &pinned,
5410            &executor,
5411            "make another change",
5412            &contract,
5413            &EventSink::test_sink(),
5414            &Arc::new(std::sync::atomic::AtomicBool::new(false)),
5415            &NativeLoopConfig {
5416                model: Some("operator-pinned".into()),
5417                exclude_models: vec!["reviewer-a".into()],
5418                ..Default::default()
5419            },
5420            &RepairMemory::disabled(),
5421            None,
5422        )
5423        .await;
5424        assert!(pinned_captured.lock().unwrap().iter().all(|intent| {
5425            intent
5426                .as_ref()
5427                .is_some_and(|hint| hint.exclude_models.is_empty() && !hint.strict_exclusions)
5428        }));
5429    }
5430
5431    #[tokio::test]
5432    async fn no_independent_coder_stops_after_one_route_attempt_as_configuration() {
5433        struct NoEligible {
5434            calls: AtomicUsize,
5435        }
5436        #[async_trait]
5437        impl TurnGenerator for NoEligible {
5438            async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5439                panic!("native coder must use the typed generation seam")
5440            }
5441
5442            async fn generate_coder(
5443                &self,
5444                _req: GenerateRequest,
5445            ) -> Result<InferenceResult, TurnGenerationError> {
5446                self.calls.fetch_add(1, Ordering::SeqCst);
5447                Err(TurnGenerationError::NoEligibleModel {
5448                    excluded_models: "reviewer-a, reviewer-b".into(),
5449                })
5450            }
5451        }
5452
5453        let dir = tempfile::tempdir().unwrap();
5454        let generator = NoEligible {
5455            calls: AtomicUsize::new(0),
5456        };
5457        let outcome = run_native_loop(
5458            &generator,
5459            &WorktreeExecutor::new(dir.path()),
5460            "make a change",
5461            &OutcomeContract {
5462                allow_credentials: false,
5463                description: "must change".into(),
5464                checks: vec![ContractCheck {
5465                    baseline: false,
5466                    differential: None,
5467                    name: "red baseline".into(),
5468                    command: crate::coder::test_cmds::FAIL.into(),
5469                    expect_exit_zero: true,
5470                    output_contains: None,
5471                    timeout_secs: 10,
5472                }],
5473            },
5474            &EventSink::test_sink(),
5475            &Arc::new(std::sync::atomic::AtomicBool::new(false)),
5476            &NativeLoopConfig {
5477                exclude_models: vec!["reviewer-a".into(), "reviewer-b".into()],
5478                ..Default::default()
5479            },
5480            &RepairMemory::disabled(),
5481            None,
5482        )
5483        .await;
5484
5485        assert_eq!(generator.calls.load(Ordering::SeqCst), 1);
5486        assert_eq!(outcome.failure, Some(LoopFailure::Configuration));
5487        let error = outcome.error.expect("configuration detail");
5488        assert!(error.contains("reviewer-a"), "{error}");
5489        assert!(error.contains("reviewer-b"), "{error}");
5490        assert!(error.contains("heal.toml"), "{error}");
5491    }
5492
5493    #[tokio::test]
5494    async fn scripted_loop_repairs_after_red_checks() {
5495        let dir = tempfile::tempdir().unwrap();
5496        let executor = WorktreeExecutor::new(dir.path());
5497        let sink = EventSink::test_sink();
5498        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5499
5500        // Iter 1: writes the WRONG content, says done → check fails.
5501        // Iter 2: fixes it → check passes.
5502        let script = Script {
5503            turns: vec![
5504                turn(
5505                    "",
5506                    serde_json::json!([{
5507                        "id": "c1", "name": "write_file",
5508                        "arguments": {"path": "x.txt", "content": "wrong"}
5509                    }]),
5510                ),
5511                turn("done", serde_json::json!([])),
5512                turn(
5513                    "",
5514                    serde_json::json!([{
5515                        "id": "c2", "name": "write_file",
5516                        "arguments": {"path": "x.txt", "content": "right"}
5517                    }]),
5518                ),
5519                turn("fixed", serde_json::json!([])),
5520            ],
5521            cursor: AtomicUsize::new(0),
5522            seen: std::sync::Mutex::new(Vec::new()),
5523        };
5524        let contract = OutcomeContract {
5525            allow_credentials: false,
5526            description: "x.txt says right".into(),
5527            checks: vec![ContractCheck {
5528                name: "content".into(),
5529                command: crate::coder::test_cmds::contains_or_report("right", "x.txt"),
5530                expect_exit_zero: true,
5531                output_contains: None,
5532                timeout_secs: 10,
5533                baseline: false,
5534                differential: None,
5535            }],
5536        };
5537
5538        let outcome = run_native_loop(
5539            &script,
5540            &executor,
5541            "write right into x.txt",
5542            &contract,
5543            &sink,
5544            &cancel,
5545            &NativeLoopConfig::default(),
5546            &RepairMemory::disabled(),
5547            None,
5548        )
5549        .await;
5550        assert!(outcome.passed);
5551        assert_eq!(outcome.iterations, 2, "one repair round expected");
5552    }
5553
5554    /// F2 (audit 2026-07-06): the coder must PERSIST its conversation across
5555    /// repair iterations — the files it read, the edits it made, the dead-ends
5556    /// it ruled out — appending the failing-check feedback as a new turn rather
5557    /// than resetting to `[System, User]` each round. Prove it: iteration 2's
5558    /// first inference must carry iteration 1's tool call.
5559    #[tokio::test]
5560    async fn f2_iteration_two_carries_iteration_one_conversation() {
5561        use std::sync::Mutex as StdMutex;
5562
5563        struct MsgCapture {
5564            seen: Arc<StdMutex<Vec<String>>>,
5565            cursor: AtomicUsize,
5566        }
5567        #[async_trait]
5568        impl TurnGenerator for MsgCapture {
5569            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5570                self.seen
5571                    .lock()
5572                    .unwrap()
5573                    .push(serde_json::to_string(&req.messages).unwrap_or_default());
5574                let i = self.cursor.fetch_add(1, Ordering::SeqCst);
5575                match i {
5576                    0 => Ok(turn(
5577                        "",
5578                        serde_json::json!([{
5579                            "id": "iter1call", "name": "write_file",
5580                            "arguments": {"path": "x.txt", "content": "ITER1_WRONG"}
5581                        }]),
5582                    )),
5583                    1 => Ok(turn("done", serde_json::json!([]))),
5584                    2 => Ok(turn(
5585                        "",
5586                        serde_json::json!([{
5587                            "id": "iter2call", "name": "write_file",
5588                            "arguments": {"path": "x.txt", "content": "ITER2_right"}
5589                        }]),
5590                    )),
5591                    _ => Ok(turn("fixed", serde_json::json!([]))),
5592                }
5593            }
5594        }
5595
5596        let dir = tempfile::tempdir().unwrap();
5597        let executor = WorktreeExecutor::new(dir.path());
5598        let sink = EventSink::test_sink();
5599        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5600        let seen = Arc::new(StdMutex::new(Vec::new()));
5601        let gen = MsgCapture {
5602            seen: seen.clone(),
5603            cursor: AtomicUsize::new(0),
5604        };
5605        let contract = OutcomeContract {
5606            allow_credentials: false,
5607            description: "x.txt says ITER2_right".into(),
5608            checks: vec![ContractCheck {
5609                name: "content".into(),
5610                command: crate::coder::test_cmds::contains("ITER2_right", "x.txt"),
5611                expect_exit_zero: true,
5612                output_contains: None,
5613                timeout_secs: 10,
5614                baseline: false,
5615                differential: None,
5616            }],
5617        };
5618
5619        let outcome = run_native_loop(
5620            &gen,
5621            &executor,
5622            "write ITER2_right into x.txt",
5623            &contract,
5624            &sink,
5625            &cancel,
5626            &NativeLoopConfig::default(),
5627            &RepairMemory::disabled(),
5628            None,
5629        )
5630        .await;
5631
5632        assert!(outcome.passed);
5633        assert_eq!(outcome.iterations, 2, "expected a repair round");
5634        let seen = seen.lock().unwrap();
5635        assert!(
5636            seen.len() >= 4,
5637            "expected >=4 inferences, got {}",
5638            seen.len()
5639        );
5640        // Iteration 2's opening inference must carry iteration 1's tool call —
5641        // the conversation is persisted, not reset.
5642        assert!(
5643            seen[2].contains("ITER1_WRONG") || seen[2].contains("iter1call"),
5644            "F2: iteration 2 lost iteration 1's conversation:\n{}",
5645            seen[2]
5646        );
5647    }
5648
5649    /// End-to-end: a `report_no_change` call leaves the loop immediately,
5650    /// carrying the nomination out UNJUDGED.
5651    ///
5652    /// The three assertions that matter are the shape of the result. It is not
5653    /// green, it is not a failure, and it carries the finding — the third shape
5654    /// #1070 exists to create. A loop that returned this as an ordinary red
5655    /// would put the caller straight back on the old behaviour.
5656    #[tokio::test]
5657    async fn a_nomination_exits_the_loop_unjudged() {
5658        let dir = tempfile::tempdir().unwrap();
5659        let executor = WorktreeExecutor::new(dir.path());
5660        let sink = EventSink::test_sink();
5661        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5662
5663        let script = Script {
5664            turns: vec![
5665                turn(
5666                    "this is already handled",
5667                    serde_json::json!([{
5668                        "id": "n1",
5669                        "name": REPORT_NO_CHANGE_TOOL,
5670                        "arguments": {
5671                            "kind": "premise_wrong",
5672                            "summary": "the handler already covers the empty case",
5673                            "evidence": "read handler.rs:88 and ran the suite green"
5674                        }
5675                    }]),
5676                ),
5677                // Must never be consumed: the nomination ends the run.
5678                turn("should not be reached", serde_json::json!([])),
5679            ],
5680            cursor: AtomicUsize::new(0),
5681            seen: std::sync::Mutex::new(Vec::new()),
5682        };
5683        let contract = OutcomeContract {
5684            allow_credentials: false,
5685            description: "noop".into(),
5686            checks: vec![],
5687        };
5688        let cfg = NativeLoopConfig {
5689            can_adjudicate_no_change: true,
5690            ..Default::default()
5691        };
5692
5693        let outcome = run_native_loop(
5694            &script,
5695            &executor,
5696            "fix the empty case",
5697            &contract,
5698            &sink,
5699            &cancel,
5700            &cfg,
5701            &RepairMemory::disabled(),
5702            None,
5703        )
5704        .await;
5705
5706        let nomination = outcome
5707            .nomination
5708            .expect("the finding must survive out of the loop");
5709        assert_eq!(nomination.kind, NoChangeKind::PremiseWrong);
5710        assert!(nomination.summary.contains("already covers"));
5711        assert!(nomination.evidence.contains("handler.rs:88"));
5712        assert!(!outcome.passed, "no diff, so not green");
5713        assert!(
5714            outcome.failure.is_none(),
5715            "a nomination is not a loss — booking it as one is the whole defect"
5716        );
5717        assert_eq!(
5718            script.cursor.load(Ordering::SeqCst),
5719            1,
5720            "the loop kept going after the nomination"
5721        );
5722    }
5723
5724    /// The same call from a caller that did NOT declare it can adjudicate must
5725    /// not become a nomination. It falls through to the executor as an unknown
5726    /// tool, and the run ends by the ordinary rules.
5727    #[tokio::test]
5728    async fn a_nomination_from_an_unprepared_caller_is_not_honoured() {
5729        let dir = tempfile::tempdir().unwrap();
5730        let executor = WorktreeExecutor::new(dir.path());
5731        let sink = EventSink::test_sink();
5732        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5733
5734        let script = Script {
5735            turns: vec![
5736                turn(
5737                    "declaring no change",
5738                    serde_json::json!([{
5739                        "id": "n1",
5740                        "name": REPORT_NO_CHANGE_TOOL,
5741                        "arguments": {
5742                            "kind": "premise_wrong",
5743                            "summary": "s",
5744                            "evidence": "e"
5745                        }
5746                    }]),
5747                ),
5748                turn("giving up", serde_json::json!([])),
5749            ],
5750            cursor: AtomicUsize::new(0),
5751            seen: std::sync::Mutex::new(Vec::new()),
5752        };
5753        let contract = OutcomeContract {
5754            allow_credentials: false,
5755            description: "noop".into(),
5756            checks: vec![],
5757        };
5758
5759        let outcome = run_native_loop(
5760            &script,
5761            &executor,
5762            "fix it",
5763            &contract,
5764            &sink,
5765            &cancel,
5766            // Default: cannot adjudicate.
5767            &NativeLoopConfig::default(),
5768            &RepairMemory::disabled(),
5769            None,
5770        )
5771        .await;
5772
5773        assert!(
5774            outcome.nomination.is_none(),
5775            "a caller that cannot judge a nomination must never receive one"
5776        );
5777    }
5778
5779    /// F3 (audit 2026-07-06): a turn that hit the max_tokens ceiling
5780    /// (stop_reason=length) is CUT OFF, not a completion. The loop must NOT
5781    /// treat an empty-tool-call truncated turn as "done" — it must recognize
5782    /// the truncation and continue so the model can finish.
5783    #[tokio::test]
5784    async fn f3_truncated_turn_is_not_treated_as_done() {
5785        let dir = tempfile::tempdir().unwrap();
5786        let executor = WorktreeExecutor::new(dir.path());
5787        let sink = EventSink::test_sink();
5788        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5789
5790        let script = Script {
5791            turns: vec![
5792                // Turn 0: truncated mid-thought — no tool calls, stop_reason=length.
5793                turn_with_stop(
5794                    "partial output that got cut o",
5795                    serde_json::json!([]),
5796                    Some("length"),
5797                ),
5798                // Turn 1: a clean, natural completion.
5799                turn_with_stop("done for real", serde_json::json!([]), Some("stop")),
5800            ],
5801            cursor: AtomicUsize::new(0),
5802            seen: std::sync::Mutex::new(Vec::new()),
5803        };
5804        let contract = OutcomeContract {
5805            allow_credentials: false,
5806            description: "noop".into(),
5807            checks: vec![],
5808        };
5809
5810        let _ = run_native_loop(
5811            &script,
5812            &executor,
5813            "do the thing",
5814            &contract,
5815            &sink,
5816            &cancel,
5817            &NativeLoopConfig::default(),
5818            &RepairMemory::disabled(),
5819            None,
5820        )
5821        .await;
5822
5823        // The truncated first turn must not end the iteration: the loop should
5824        // continue and consume the SECOND scripted turn (cursor advances to 2).
5825        assert_eq!(
5826            script.cursor.load(Ordering::SeqCst),
5827            2,
5828            "truncated turn was mistaken for completion — loop stopped early instead of continuing"
5829        );
5830    }
5831
5832    /// End-to-end learning: a repair (red → green) records a durable skill
5833    /// keyed on the failure signature, and a *fresh* session that hits the same
5834    /// signature recalls that approach into its repair prompt.
5835    #[tokio::test]
5836    async fn repair_round_learns_and_recalls_across_sessions() {
5837        use crate::coder::skill_memory::FailureSignature;
5838        use car_memgine::MemgineEngine;
5839        use tokio::sync::Mutex as AsyncMutex;
5840
5841        // A shared memgine survives both sessions (the "gets better" store).
5842        let memory = RepairMemory::new(Some(Arc::new(AsyncMutex::new(MemgineEngine::new(None)))));
5843
5844        // A contract whose check fails LOUDLY with a recognizable error class
5845        // so the signature is stable across sessions.
5846        let contract = OutcomeContract {
5847            allow_credentials: false,
5848            description: "x.txt says right".into(),
5849            checks: vec![ContractCheck {
5850                name: "content".into(),
5851                command: crate::coder::test_cmds::contains_or_report("right", "x.txt"),
5852                expect_exit_zero: true,
5853                output_contains: None,
5854                timeout_secs: 10,
5855                baseline: false,
5856                differential: None,
5857            }],
5858        };
5859        let sig = FailureSignature {
5860            check: "content".into(),
5861            error_class: "test_failure".into(),
5862        };
5863
5864        // --- Session 1: red then green. The green-after-red ingests the skill.
5865        let dir1 = tempfile::tempdir().unwrap();
5866        let exec1 = WorktreeExecutor::new(dir1.path());
5867        let sink = EventSink::test_sink();
5868        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5869        let script1 = Script {
5870            turns: vec![
5871                turn(
5872                    "",
5873                    serde_json::json!([{
5874                        "id": "c1", "name": "write_file",
5875                        "arguments": {"path": "x.txt", "content": "wrong"}
5876                    }]),
5877                ),
5878                turn("nothing useful yet", serde_json::json!([])),
5879                turn(
5880                    "",
5881                    serde_json::json!([{
5882                        "id": "c2", "name": "write_file",
5883                        "arguments": {"path": "x.txt", "content": "right"}
5884                    }]),
5885                ),
5886                turn(
5887                    "wrote 'right' into x.txt to satisfy the grep",
5888                    serde_json::json!([]),
5889                ),
5890            ],
5891            cursor: AtomicUsize::new(0),
5892            seen: std::sync::Mutex::new(Vec::new()),
5893        };
5894        let outcome1 = run_native_loop(
5895            &script1,
5896            &exec1,
5897            "write right into x.txt",
5898            &contract,
5899            &sink,
5900            &cancel,
5901            &NativeLoopConfig::default(),
5902            &memory,
5903            None,
5904        )
5905        .await;
5906        assert!(outcome1.passed);
5907        // The winning approach is now durably recallable for this signature.
5908        let recalled = memory
5909            .recall(&sig)
5910            .await
5911            .expect("session 1 should have learned");
5912        assert!(recalled.contains("right"), "approach captured: {recalled}");
5913
5914        // --- Session 2: the SAME signature recurs. The loop must inject the
5915        // recalled hint into the repair prompt on the second iteration.
5916        let dir2 = tempfile::tempdir().unwrap();
5917        let exec2 = WorktreeExecutor::new(dir2.path());
5918        let (sink2, collected) = EventSink::collecting("coder-learn");
5919        let seen_hint = Arc::new(std::sync::atomic::AtomicBool::new(false));
5920
5921        // A generator that asserts on the prompt it receives: once a recall
5922        // hint shows up in the user message, it writes the fix.
5923        struct HintWatcher {
5924            seen: Arc<std::sync::atomic::AtomicBool>,
5925            cursor: AtomicUsize,
5926        }
5927        #[async_trait]
5928        impl TurnGenerator for HintWatcher {
5929            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5930                let i = self.cursor.fetch_add(1, Ordering::SeqCst);
5931                let saw_hint = req
5932                    .messages
5933                    .as_ref()
5934                    .map(|ms| {
5935                        ms.iter().any(
5936                            |m| matches!(m, Message::User { content } if content.contains("HINT")),
5937                        )
5938                    })
5939                    .unwrap_or(false);
5940                if saw_hint {
5941                    self.seen.store(true, Ordering::SeqCst);
5942                }
5943                Ok(match i {
5944                    // Iter 1: do nothing → contract red → signature recorded.
5945                    0 => turn("did nothing", serde_json::json!([])),
5946                    // Iter 2 (hint present): write the fix.
5947                    1 => turn(
5948                        "",
5949                        serde_json::json!([{
5950                            "id": "c1", "name": "write_file",
5951                            "arguments": {"path": "x.txt", "content": "right"}
5952                        }]),
5953                    ),
5954                    _ => turn("applied the recalled fix", serde_json::json!([])),
5955                })
5956            }
5957        }
5958
5959        let script2 = HintWatcher {
5960            seen: seen_hint.clone(),
5961            cursor: AtomicUsize::new(0),
5962        };
5963        let outcome2 = run_native_loop(
5964            &script2,
5965            &exec2,
5966            "write right into x.txt",
5967            &contract,
5968            &sink2,
5969            &cancel,
5970            &NativeLoopConfig::default(),
5971            &memory,
5972            None,
5973        )
5974        .await;
5975        assert!(outcome2.passed, "session 2 should pass: {outcome2:?}");
5976        assert!(
5977            seen_hint.load(Ordering::SeqCst),
5978            "the recalled hint must have been injected into the repair prompt"
5979        );
5980        drop(collected);
5981    }
5982
5983    /// The `ask_user` tool routes to the [`AskUser`] handler (not the worktree
5984    /// executor), emits `UserInputRequested`, and the handler's answer is fed
5985    /// back to the model as the tool result — which the model then uses.
5986    #[tokio::test]
5987    async fn ask_user_tool_routes_to_handler_and_answer_reaches_model() {
5988        use std::sync::Mutex as StdMutex;
5989
5990        let dir = tempfile::tempdir().unwrap();
5991        let executor = WorktreeExecutor::new(dir.path());
5992        let (sink, collected) = EventSink::collecting("coder-ask");
5993        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5994
5995        // A canned asker that records the prompt it saw and returns a fixed
5996        // answer (stands in for the gate + coder.respond round-trip).
5997        struct CannedAsker {
5998            seen_prompt: Arc<StdMutex<Option<String>>>,
5999            answer: String,
6000        }
6001        #[async_trait]
6002        impl AskUser for CannedAsker {
6003            async fn ask(&self, prompt: &str) -> Result<String, String> {
6004                *self.seen_prompt.lock().unwrap() = Some(prompt.to_string());
6005                Ok(self.answer.clone())
6006            }
6007        }
6008        let seen_prompt = Arc::new(StdMutex::new(None));
6009        let asker = CannedAsker {
6010            seen_prompt: seen_prompt.clone(),
6011            answer: "use port 8080".to_string(),
6012        };
6013
6014        // The script: ask a question, then (turn 2) write the answer it got
6015        // back into a file, then declare done. A generator that echoes the
6016        // ask_user tool result into the write proves the answer reached it.
6017        struct AskThenWrite {
6018            cursor: AtomicUsize,
6019        }
6020        #[async_trait]
6021        impl TurnGenerator for AskThenWrite {
6022            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
6023                let i = self.cursor.fetch_add(1, Ordering::SeqCst);
6024                match i {
6025                    0 => Ok(turn(
6026                        "",
6027                        serde_json::json!([{
6028                            "id": "a1", "name": "ask_user",
6029                            "arguments": {"prompt": "which port?"}
6030                        }]),
6031                    )),
6032                    1 => {
6033                        // Pull the answer out of the ToolResult the loop appended.
6034                        let answer = req
6035                            .messages
6036                            .as_ref()
6037                            .and_then(|ms| {
6038                                ms.iter().rev().find_map(|m| match m {
6039                                    Message::ToolResult { content, .. } => Some(content.clone()),
6040                                    _ => None,
6041                                })
6042                            })
6043                            .unwrap_or_default();
6044                        Ok(turn(
6045                            "",
6046                            serde_json::json!([{
6047                                "id": "w1", "name": "write_file",
6048                                "arguments": {"path": "answer.txt", "content": answer}
6049                            }]),
6050                        ))
6051                    }
6052                    _ => Ok(turn("done", serde_json::json!([]))),
6053                }
6054            }
6055        }
6056
6057        let contract = OutcomeContract {
6058            allow_credentials: false,
6059            description: "answer.txt records the chosen port".into(),
6060            checks: vec![ContractCheck {
6061                name: "has_port".into(),
6062                command: crate::coder::test_cmds::contains("8080", "answer.txt"),
6063                expect_exit_zero: true,
6064                output_contains: None,
6065                timeout_secs: 10,
6066                baseline: false,
6067                differential: None,
6068            }],
6069        };
6070
6071        let outcome = run_native_loop(
6072            &AskThenWrite {
6073                cursor: AtomicUsize::new(0),
6074            },
6075            &executor,
6076            "pick a port and record it",
6077            &contract,
6078            &sink,
6079            &cancel,
6080            &NativeLoopConfig::default(),
6081            &RepairMemory::disabled(),
6082            Some(&asker),
6083        )
6084        .await;
6085
6086        assert!(outcome.passed, "outcome: {outcome:?}");
6087        // The handler saw the model's question.
6088        assert_eq!(seen_prompt.lock().unwrap().as_deref(), Some("which port?"));
6089        // The answer reached the model and was written through.
6090        assert_eq!(
6091            std::fs::read_to_string(dir.path().join("answer.txt")).unwrap(),
6092            "use port 8080"
6093        );
6094        // The ask_user call surfaced in the event stream as a tool call (the
6095        // semantic UserInputRequested event is the GateAsker's job, covered by
6096        // the rpc round-trip test).
6097        let events = collected.lock().unwrap();
6098        assert!(events.iter().any(|e| matches!(
6099            &e.kind,
6100            CoderEventKind::ToolCall { tool, .. } if tool == ASK_USER_TOOL
6101        )));
6102    }
6103
6104    /// Without an asker the `ask_user` tool is not offered, and if a model calls
6105    /// it anyway the loop returns a recoverable tool error rather than wedging.
6106    #[tokio::test]
6107    async fn ask_user_without_handler_is_a_recoverable_error() {
6108        let dir = tempfile::tempdir().unwrap();
6109        let executor = WorktreeExecutor::new(dir.path());
6110        let (sink, _collected) = EventSink::collecting("coder-noask");
6111        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
6112
6113        // ask_user is absent from the offered tools when ask is None.
6114        struct ToolPeek {
6115            offered: Arc<std::sync::atomic::AtomicBool>,
6116        }
6117        #[async_trait]
6118        impl TurnGenerator for ToolPeek {
6119            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
6120                let has_ask = req
6121                    .tools
6122                    .as_ref()
6123                    .map(|ts| ts.iter().any(|t| t["name"] == ASK_USER_TOOL))
6124                    .unwrap_or(false);
6125                self.offered.store(has_ask, Ordering::SeqCst);
6126                Ok(turn("done", serde_json::json!([])))
6127            }
6128        }
6129        let offered_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
6130        let contract = OutcomeContract {
6131            allow_credentials: false,
6132            description: "noop".into(),
6133            checks: vec![ContractCheck {
6134                name: "ok".into(),
6135                command: crate::coder::test_cmds::PASS.to_string(),
6136                expect_exit_zero: true,
6137                output_contains: None,
6138                timeout_secs: 10,
6139                baseline: false,
6140                differential: None,
6141            }],
6142        };
6143        let _ = run_native_loop(
6144            &ToolPeek {
6145                offered: offered_flag.clone(),
6146            },
6147            &executor,
6148            "x",
6149            &contract,
6150            &sink,
6151            &cancel,
6152            &NativeLoopConfig::default(),
6153            &RepairMemory::disabled(),
6154            None,
6155        )
6156        .await;
6157        assert!(
6158            !offered_flag.load(Ordering::SeqCst),
6159            "ask_user must not be offered when no handler is wired"
6160        );
6161    }
6162
6163    #[tokio::test]
6164    async fn cancellation_stops_the_loop() {
6165        let dir = tempfile::tempdir().unwrap();
6166        let executor = WorktreeExecutor::new(dir.path());
6167        let sink = EventSink::test_sink();
6168        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(true));
6169        let script = Script {
6170            turns: vec![],
6171            cursor: AtomicUsize::new(0),
6172            seen: std::sync::Mutex::new(Vec::new()),
6173        };
6174        let contract = OutcomeContract {
6175            allow_credentials: false,
6176            description: "d".into(),
6177            checks: vec![ContractCheck {
6178                name: "never".into(),
6179                command: crate::coder::test_cmds::PASS.to_string(),
6180                expect_exit_zero: true,
6181                output_contains: None,
6182                timeout_secs: 10,
6183                baseline: false,
6184                differential: None,
6185            }],
6186        };
6187        let outcome = run_native_loop(
6188            &script,
6189            &executor,
6190            "x",
6191            &contract,
6192            &sink,
6193            &cancel,
6194            &NativeLoopConfig::default(),
6195            &RepairMemory::disabled(),
6196            None,
6197        )
6198        .await;
6199        assert_eq!(outcome.error.as_deref(), Some("cancelled"));
6200        assert_eq!(outcome.iterations, 0);
6201    }
6202
6203    #[test]
6204    fn failure_feedback_lists_only_failures() {
6205        let results = vec![
6206            CheckResult {
6207                credentials_allowed: false,
6208                name: "good".into(),
6209                passed: true,
6210                exit_code: Some(0),
6211                output_tail: "ok".into(),
6212                duration_ms: 1,
6213                timed_out: false,
6214                deadline_clamped: false,
6215            },
6216            CheckResult {
6217                credentials_allowed: false,
6218                name: "bad".into(),
6219                passed: false,
6220                exit_code: Some(1),
6221                output_tail: "assertion failed".into(),
6222                duration_ms: 1,
6223                timed_out: false,
6224                deadline_clamped: false,
6225            },
6226        ];
6227        let fb = failure_feedback(&results, 0);
6228        assert!(fb.contains("FAILED bad"));
6229        assert!(fb.contains("assertion failed"));
6230        assert!(!fb.contains("FAILED good"));
6231        // A fresh failure gets the read-the-error direction, not the escalation.
6232        assert!(fb.contains("name the single cause"));
6233        assert!(!fb.contains("failed 2 times in a row"));
6234    }
6235
6236    #[test]
6237    fn failure_feedback_escalates_on_a_recurring_failure() {
6238        let results = vec![CheckResult {
6239            credentials_allowed: false,
6240            name: "run_tests".into(),
6241            passed: false,
6242            exit_code: Some(1),
6243            output_tail: "AttributeError: no attribute '_remove_slot_root'".into(),
6244            duration_ms: 1,
6245            timed_out: false,
6246            deadline_clamped: false,
6247        }];
6248        // Same signature failing a 2nd time (recurrences=1) → escalate: stop
6249        // repeating the approach, implement the named missing symbol.
6250        let fb = failure_feedback(&results, 1);
6251        assert!(fb.contains("failed the same way 2 times"), "{fb}");
6252        assert!(fb.contains("do NOT re-apply a variation"));
6253        assert!(fb.contains("IMPLEMENT it"));
6254    }
6255
6256    #[test]
6257    fn system_prompt_carries_the_contract() {
6258        let contract = OutcomeContract {
6259            allow_credentials: false,
6260            description: "make the tests pass".into(),
6261            checks: vec![super::super::contract::ContractCheck {
6262                name: "tests".into(),
6263                command: "cargo test -p demo".into(),
6264                expect_exit_zero: true,
6265                output_contains: None,
6266                timeout_secs: 300,
6267                baseline: false,
6268                differential: None,
6269            }],
6270        };
6271        let p = system_prompt(&contract, "Top-level entries: Cargo.toml, src", None);
6272        assert!(p.contains("cargo test -p demo"));
6273        assert!(p.contains("STOP calling tools"));
6274        // The coder must self-verify with the contract's exact command, not a
6275        // broad guess (a broad run in a large repo trips on unrelated breakage).
6276        assert!(p.contains("EXACT command(s) from the OUTCOME CONTRACT"));
6277    }
6278
6279    #[test]
6280    fn coder_prompt_contains_discipline_and_keeps_stop_contract() {
6281        let contract = OutcomeContract {
6282            allow_credentials: false,
6283            description: "make the tests pass".into(),
6284            checks: vec![ContractCheck {
6285                name: "tests".into(),
6286                command: "cargo test -p demo".into(),
6287                expect_exit_zero: true,
6288                output_contains: None,
6289                timeout_secs: 300,
6290                baseline: false,
6291                differential: None,
6292            }],
6293        };
6294        let env = "Top-level entries: Cargo.toml, src\nBuild systems detected: Rust (cargo)";
6295        let p = system_prompt(&contract, env, None);
6296
6297        // Real coding discipline is present.
6298        assert!(p.contains("Inspect before you edit"), "inspect-first");
6299        assert!(
6300            p.contains("grep_files") && p.contains("find_files"),
6301            "search-before-read discipline"
6302        );
6303        assert!(p.contains("prefer edit_file"), "surgical-edit discipline");
6304        assert!(
6305            p.contains("Never fabricate file contents"),
6306            "anti-fabrication (files)"
6307        );
6308        assert!(
6309            p.contains("Never claim a check passed"),
6310            "anti-fabrication (results)"
6311        );
6312        assert!(
6313            p.contains("read the actual error output before retrying"),
6314            "read-the-error discipline"
6315        );
6316        // Dogfooding fix #1: a blocked self-verify must not be reported as failure.
6317        assert!(
6318            p.contains("is NOT a task failure") && p.contains("blocked"),
6319            "blocked-verification-is-not-failure guidance"
6320        );
6321        // Dogfooding fix #3 (evidence-backed A/B, 55%->100%): trace each check.
6322        // The task-specific pitfall it also carried ("a bare set() loses order")
6323        // was one eval's failure promoted to a permanent global rule; language-
6324        // specific correctness guidance belongs in a contract check or a `.car/`
6325        // rubric, not in every coder turn forever.
6326        assert!(
6327            p.contains("Trace the checks before you declare done"),
6328            "check-tracing guidance"
6329        );
6330        assert!(
6331            !p.contains("set()"),
6332            "no eval-specific correctness hints in the global prompt"
6333        );
6334        // Dogfooding fix (A/B 809s vs 58s root-cause): the coder fixed the bug in
6335        // ~6 turns then burned ~30 fighting a python3.14 self-verify env mismatch —
6336        // ran plain `python -m pytest` (wrong interpreter), then wrote
6337        // sitecustomize.py/UserDict.py shims + pip install to "repair" it. Verify
6338        // with the contract's EXACT command; the env-repair BAN itself now lives in
6339        // `coder::policy::DenyEnvironmentRepair`, where it is enforced rather than
6340        // merely stated, so the prompt carries only the judgment half.
6341        assert!(
6342            p.contains("copy the command string character-for-character"),
6343            "exact-command self-verify (no broad substitute)"
6344        );
6345        assert!(
6346            p.contains("The environment is not yours to fix") && p.contains("denied by policy"),
6347            "environment repair: judgment in the prompt, enforcement in policy"
6348        );
6349        assert!(
6350            !p.contains("STRICTLY FORBIDDEN"),
6351            "the enumerated prose blacklist moved to the inspector chain"
6352        );
6353
6354        // The runtime-verifies framing is REFRAMED (verify yourself first), not
6355        // a promise the runtime does it for you.
6356        assert!(p.contains("do not rely on it: verify the checks yourself first"));
6357
6358        // Load-bearing behavior contracts are preserved verbatim / accurately.
6359        assert!(
6360            p.contains("reply with a brief plain-text summary and STOP calling tools"),
6361            "the STOP-calling-tools loop-termination contract must survive verbatim"
6362        );
6363        // `git commit` is deliberately NOT denied by the inspector chain (see
6364        // coder::policy::tests::git_push_and_remote_mutation_denied), so this
6365        // prose line is the only thing holding the rule — it must survive.
6366        assert!(p.contains("Do not git commit"), "policy: no git commit");
6367        // car#1074: the prompt used to claim a class ("push, sudo, destructive
6368        // operations") that the chain did not implement — `gh pr create` was
6369        // allowed. `coder::policy::DenyForgePublication` closes that, and the
6370        // prompt must name the route it actually blocks.
6371        assert!(
6372            p.contains("gh pr create") && p.contains("the runtime opens the"),
6373            "publication is denied by any route, and the runtime does the publishing"
6374        );
6375        assert!(
6376            p.contains("Read-only forge commands"),
6377            "the allowed half of the forge guard must be stated, not just the denied half"
6378        );
6379        // The ENVIRONMENT section (F7/L1) carries the repo summary.
6380        assert!(p.contains("ENVIRONMENT:"));
6381        assert!(p.contains("Rust (cargo)"));
6382        // And the outcome contract is still rendered.
6383        assert!(p.contains("cargo test -p demo"));
6384    }
6385
6386    #[test]
6387    fn preview_truncates_on_char_boundary() {
6388        assert_eq!(preview("short", 10), "short");
6389        let long = "é".repeat(300);
6390        let p = preview(&long, 5);
6391        assert!(p.ends_with('…') && p.chars().count() <= 4);
6392    }
6393
6394    /// A generator that captures the FIRST user message of the request it
6395    /// receives, then declares done — for asserting what the model sees on the
6396    /// opening turn.
6397    struct FirstUserCapture {
6398        captured: Arc<std::sync::Mutex<String>>,
6399    }
6400    #[async_trait]
6401    impl TurnGenerator for FirstUserCapture {
6402        async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
6403            let first_user = req
6404                .messages
6405                .as_ref()
6406                .and_then(|ms| {
6407                    ms.iter().find_map(|m| match m {
6408                        Message::User { content } => Some(content.clone()),
6409                        _ => None,
6410                    })
6411                })
6412                .unwrap_or_default();
6413            *self.captured.lock().unwrap() = first_user;
6414            Ok(turn("done", serde_json::json!([])))
6415        }
6416    }
6417
6418    fn trivial_contract() -> OutcomeContract {
6419        OutcomeContract {
6420            allow_credentials: false,
6421            description: "trivial".into(),
6422            checks: vec![ContractCheck {
6423                name: "ok".into(),
6424                command: crate::coder::test_cmds::PASS.to_string(),
6425                expect_exit_zero: true,
6426                output_contains: None,
6427                timeout_secs: 10,
6428                baseline: false,
6429                differential: None,
6430            }],
6431        }
6432    }
6433
6434    #[tokio::test]
6435    async fn coder_first_message_carries_recall_when_facts_exist() {
6436        use crate::coder::skill_memory::FailureSignature;
6437        use car_memgine::MemgineEngine;
6438        use tokio::sync::Mutex as AsyncMutex;
6439
6440        // Seed a shared engine with a prior-session repair lead overlapping the
6441        // intent keywords ("tests").
6442        let memory = RepairMemory::new(Some(Arc::new(AsyncMutex::new(MemgineEngine::new(None)))));
6443        let sig = FailureSignature {
6444            check: "tests".into(),
6445            error_class: "test_failure".into(),
6446        };
6447        memory
6448            .record_success(&sig, "add the missing import and re-run cargo test")
6449            .await;
6450
6451        let dir = tempfile::tempdir().unwrap();
6452        let executor = WorktreeExecutor::new(dir.path());
6453        let sink = EventSink::test_sink();
6454        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
6455        let captured = Arc::new(std::sync::Mutex::new(String::new()));
6456
6457        let outcome = run_native_loop(
6458            &FirstUserCapture {
6459                captured: captured.clone(),
6460            },
6461            &executor,
6462            "the tests are failing, please fix them",
6463            &trivial_contract(),
6464            &sink,
6465            &cancel,
6466            &NativeLoopConfig::default(),
6467            &memory,
6468            None,
6469        )
6470        .await;
6471        assert!(outcome.passed, "outcome: {outcome:?}");
6472
6473        let first_user = captured.lock().unwrap().clone();
6474        assert!(
6475            first_user.contains("Recall from prior sessions"),
6476            "the labelled session-start recall must be in the first user turn: {first_user}"
6477        );
6478        assert!(
6479            first_user.contains("missing import"),
6480            "the recalled approach content rides along: {first_user}"
6481        );
6482    }
6483
6484    #[tokio::test]
6485    async fn coder_first_message_recall_absent_when_empty() {
6486        use car_memgine::MemgineEngine;
6487        use tokio::sync::Mutex as AsyncMutex;
6488
6489        // A live engine with NOTHING learned → recall_for_task returns None →
6490        // no recall section is injected (no empty boilerplate).
6491        let memory = RepairMemory::new(Some(Arc::new(AsyncMutex::new(MemgineEngine::new(None)))));
6492
6493        let dir = tempfile::tempdir().unwrap();
6494        let executor = WorktreeExecutor::new(dir.path());
6495        let sink = EventSink::test_sink();
6496        let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
6497        let captured = Arc::new(std::sync::Mutex::new(String::new()));
6498
6499        let outcome = run_native_loop(
6500            &FirstUserCapture {
6501                captured: captured.clone(),
6502            },
6503            &executor,
6504            "the tests are failing, please fix them",
6505            &trivial_contract(),
6506            &sink,
6507            &cancel,
6508            &NativeLoopConfig::default(),
6509            &memory,
6510            None,
6511        )
6512        .await;
6513        assert!(outcome.passed, "outcome: {outcome:?}");
6514
6515        let first_user = captured.lock().unwrap().clone();
6516        assert!(
6517            !first_user.contains("Recall from prior sessions"),
6518            "no recall section when the engine has nothing relevant: {first_user}"
6519        );
6520    }
6521}