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