Skip to main content

car_server_core/coder/
contract.rs

1//! Outcome contracts — the verifiable definition of "done" for a coder session.
2//!
3//! A contract is a set of shell commands that must pass inside the worktree.
4//! It is derived from the user's intent by a model (with a bounded repair loop
5//! mirroring `car-builder`: generation is an injected closure, so tests run
6//! without inference) and then becomes the trust boundary for the whole
7//! session: whatever engine did the work — the native loop or an external CLI
8//! — the runtime re-runs the checks itself before asking for merge approval.
9//!
10//! # What a contract can and cannot assert
11//!
12//! The point-in-time assertion vocabulary is
13//! [`ContractCheck::expect_exit_zero`] and
14//! [`ContractCheck::output_contains`]. The latter normally checks a substring;
15//! its reserved `$json:<pointer>=<json>` form performs runtime-owned JSON value
16//! equality. That is not the boundary, though: the command is graded on its exit code, so a threshold
17//! against a live system (`[ "$(…)" -lt 100000 ]`) is a legal check today, and
18//! contract checks are deliberately not de-credentialed, which is why they run
19//! through `WorktreeExecutor::run_check_shell` rather than the model's own
20//! `run_shell`.
21//!
22//! **Before/after claims are expressible too** (Parslee-ai/car#1067). A check
23//! marked [`ContractCheck::baseline`] is a *capture*: it runs once, at session
24//! start, inside [`evaluate_contract_baseline`]'s pass over the unmodified
25//! worktree, and its output is kept ([`BaselineCaptures`], built by
26//! [`collect_baseline_captures`]). A check carrying a
27//! [`ContractCheck::differential`] runs at every later evaluation and its
28//! output is compared against the named capture by the RUNTIME — exhaustively
29//! one of [`DifferentialExpect::Changed`], [`DifferentialExpect::Unchanged`]
30//! (the control-group claim), or [`DifferentialExpect::DeltaWithin`] (numeric
31//! delta bounds). The command is arbitrary — a row count, a file digest, a
32//! `curl` body — so the external subject falls out of the command, subject to
33//! the same policy chain as every check (car#1066); what this adds is the
34//! before/after structure. Both executions are runtime-owned and both results
35//! are stamped into the session's events (the capture in `contract_baseline`,
36//! the comparison in `check_completed`): model claims count for nothing.
37//!
38//! What is still missing is an **evaluation point past delivery**. Every
39//! evaluation is on this side of it:
40//! [`evaluate_contract_baseline`] against the unmodified worktree, then
41//! [`evaluate_contract`] once per repair round inside the loop, then
42//! [`evaluate_contract_within`] as the gate that admits delivery. Only
43//! `car code-task` runs that gate as a *separate* pass after the loop, holding
44//! the loop's own verdict advisory; a daemon session finalizes straight off the
45//! loop's last evaluation, which is the same call one layer down. Nothing runs
46//! after either way. So a claim about what a DEPLOY changed ("the row count
47//! fell *after the deploy*") still belongs to the orchestrator wrapping the
48//! session, which owns the deploy and both sides of that window — the
49//! differential machinery here measures across the session's *work*, not
50//! across a deploy the session never performs. See `docs/car-code-task.md`.
51
52use serde::{Deserialize, Serialize};
53use serde_json::Value;
54use std::collections::HashMap;
55use std::future::Future;
56use std::time::Duration;
57
58/// Per-attempt cap on the contract-derivation generation call. Without it, a
59/// hung inference backend (e.g. no usable local model — see PAR-7169/PAR-7264)
60/// makes `derive_contract` block indefinitely, so `Derive contract` / `car code`
61/// just sits on "deriving outcome contract…" forever and orphans a 0-byte event
62/// log (PAR-7170). With it, a stuck attempt fails fast with an actionable error.
63const CONTRACT_GEN_TIMEOUT: Duration = Duration::from_secs(120);
64
65use super::budget::SessionDeadline;
66use super::session::{CoderEventKind, EventSink};
67use super::shell_tool::WorktreeExecutor;
68
69fn default_true() -> bool {
70    true
71}
72
73fn default_check_timeout() -> u64 {
74    120
75}
76
77/// The verifiable definition of done for a coding session.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct OutcomeContract {
80    /// Human summary of what success means.
81    pub description: String,
82    /// Checks that must all pass. Evaluated through the same policy-gated
83    /// shell inspector chain as the agent's own calls, so a project
84    /// `deny_tool` rule refuses a "check" exactly as it would a tool call.
85    /// The *credential* posture differs — see the module docs.
86    pub checks: Vec<ContractCheck>,
87}
88
89/// One acceptance check: a shell command run at the worktree root.
90///
91/// [`expect_exit_zero`](Self::expect_exit_zero) and
92/// [`output_contains`](Self::output_contains) are the point-in-time assertion
93/// vocabulary (including its reserved semantic JSON form). Before/after claims
94/// use [`baseline`](Self::baseline) + [`differential`](Self::differential) —
95/// see the module docs for the model and its limits.
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct ContractCheck {
98    /// Short, unique label ("tests_pass", "file_created").
99    pub name: String,
100    /// Command run via the worktree shell tool.
101    pub command: String,
102    /// Require exit code 0 (default true).
103    #[serde(default = "default_true")]
104    pub expect_exit_zero: bool,
105    /// Additionally require this substring in the combined output. A value in
106    /// the reserved `$json:<pointer>=<json>` form is instead evaluated by the
107    /// runtime as a semantic assertion against the command's JSON output.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub output_contains: Option<String>,
110    /// Per-check timeout (default 120s; the shell tool clamps further).
111    #[serde(default = "default_check_timeout")]
112    pub timeout_secs: u64,
113    /// Capture-only check: run once at session start (during the baseline
114    /// pass) and its output kept as the before-value other checks may diff
115    /// against by [`name`](Self::name). At the gating evaluations it is NOT
116    /// re-run — re-capturing "before" after the work would destroy the
117    /// comparison — its capture-time result is carried into the results
118    /// instead, so a failed capture keeps the gate red rather than vanishing.
119    ///
120    /// Additive and `serde(default)`, so every existing contract parses
121    /// unchanged.
122    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
123    pub baseline: bool,
124    /// Differential assertion: after this check's command runs, compare its
125    /// output against the named baseline capture. Evaluated by the runtime —
126    /// exhaustively one of changed / unchanged / delta-within-bounds — in
127    /// ADDITION to `expect_exit_zero` / `output_contains`, never instead.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub differential: Option<DifferentialCheck>,
130}
131
132/// A before/after claim: compare this check's output against a named
133/// [`baseline`](ContractCheck::baseline) capture.
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct DifferentialCheck {
136    /// Name of the baseline-marked check whose captured output is the
137    /// before-value. Must be declared BEFORE this check in the contract —
138    /// captures accumulate in declaration order during the baseline pass.
139    pub baseline: String,
140    /// The claim itself.
141    pub expect: DifferentialExpect,
142}
143
144/// The differential claims the runtime can decide. Matching is exhaustive
145/// everywhere — a new variant must be handled at every site or the build
146/// fails, which is the point.
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum DifferentialExpect {
150    /// The output must differ from the captured baseline ("this trace now
151    /// appears and did not before", "the heartbeat flipped").
152    Changed,
153    /// The output must be identical to the captured baseline — the
154    /// control-group claim ("the other tenant's rows did not move").
155    Unchanged,
156    /// Both outputs must carry a number, and `after - before` must fall within
157    /// the stated bounds (either side optional, at least one required —
158    /// [`OutcomeContract::validate`] rejects the unbounded form). "Orphaned
159    /// rows fell by at least 100" is `{ "max": -100.0 }`.
160    DeltaWithin {
161        #[serde(default, skip_serializing_if = "Option::is_none")]
162        min: Option<f64>,
163        #[serde(default, skip_serializing_if = "Option::is_none")]
164        max: Option<f64>,
165    },
166}
167
168/// Baseline captures by capturing check name — the before-values differential
169/// checks compare against. Built from the session-start baseline pass by
170/// [`collect_baseline_captures`]; the capture is the check's [`CheckResult`],
171/// whose `output_tail` (the 4 KiB tail) is the compared value, so keep a
172/// capture command's output small and deterministic (a count, a digest, a
173/// status line — not a full dump).
174pub type BaselineCaptures = HashMap<String, CheckResult>;
175
176/// Extract the baseline captures from a session-start baseline pass: the
177/// results of every check the contract marks [`ContractCheck::baseline`].
178pub fn collect_baseline_captures(
179    contract: &OutcomeContract,
180    baseline_results: &[CheckResult],
181) -> BaselineCaptures {
182    contract
183        .checks
184        .iter()
185        .filter(|c| c.baseline)
186        .filter_map(|c| {
187            baseline_results
188                .iter()
189                .find(|r| r.name == c.name)
190                .map(|r| (c.name.clone(), r.clone()))
191        })
192        .collect()
193}
194
195/// Result of evaluating one [`ContractCheck`].
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct CheckResult {
198    pub name: String,
199    pub passed: bool,
200    /// None when the command could not run at all (spawn/policy failure).
201    pub exit_code: Option<i64>,
202    /// Tail of combined stdout+stderr — enough for repair prompts and the UI.
203    pub output_tail: String,
204    pub duration_ms: u64,
205    /// The command was killed at a timeout instead of exiting on its own.
206    ///
207    /// Additive and `serde(default)`, so results persisted before this field
208    /// existed still deserialize.
209    #[serde(default)]
210    pub timed_out: bool,
211    /// The timeout it was killed at was the SESSION's remaining budget, not the
212    /// check's own effective ceiling — see [`deadline_set_the_timeout`], which
213    /// is where "effective" is load-bearing: the ceiling is
214    /// `min(timeout_secs, max_check_timeout_secs)`, not the declared
215    /// `timeout_secs`.
216    #[serde(default)]
217    pub deadline_clamped: bool,
218}
219
220impl CheckResult {
221    /// This result is **not a verdict on the work**: the check was killed by
222    /// the session clock before it could reach one.
223    ///
224    /// Anyone deciding what to book a red gate as has to ask this first. A
225    /// starved check says nothing about whether the change is correct — it says
226    /// the run was out of time — so scoring it as a task loss books a budget
227    /// decision against the model. Both bits are needed to tell them apart: a
228    /// check that blew its OWN `timeout_secs` is a genuine red (a hang is a
229    /// defect), and only one clamped down to the session's leftover seconds is
230    /// starved.
231    pub fn starved_by_deadline(&self) -> bool {
232        self.timed_out && self.deadline_clamped
233    }
234}
235
236impl OutcomeContract {
237    /// Structural problems that make a contract unusable. Empty = valid.
238    ///
239    /// Beyond pure structure (empty/duplicate names, assertion-less checks)
240    /// this also rejects two failure modes seen live from small local models
241    /// (issue #168 follow-up): the prompt's literal placeholder name leaking
242    /// through verbatim, and "toolchain-only" no-op commands like
243    /// `cargo --version` that prove nothing about the change. Both pass the
244    /// structural checks but make a contract that gates nothing, so they're
245    /// surfaced as validation issues to drive the repair loop rather than
246    /// silently becoming the trust boundary.
247    pub fn validate(&self) -> Vec<String> {
248        let mut issues = Vec::new();
249        if self.checks.is_empty() {
250            issues.push("contract has no checks — at least one is required".to_string());
251        }
252        let mut seen = std::collections::HashSet::new();
253        for (i, c) in self.checks.iter().enumerate() {
254            let name = c.name.trim();
255            if name.is_empty() {
256                issues.push(format!("check #{i} has an empty name"));
257            }
258            if name == "unique_snake_case_label" {
259                issues.push(format!(
260                    "check #{i} kept the literal placeholder name \
261                     'unique_snake_case_label' — give it a real descriptive label"
262                ));
263            }
264            if c.command.trim().is_empty() {
265                issues.push(format!("check '{}' has an empty command", c.name));
266            } else if is_toolchain_only(c.command.trim()) {
267                issues.push(format!(
268                    "check '{}' runs a toolchain-only no-op (`{}`) that verifies the \
269                     tool is installed, not the task — replace it with a command that \
270                     exercises the actual change",
271                    c.name,
272                    c.command.trim()
273                ));
274            }
275            if !seen.insert(name.to_string()) {
276                issues.push(format!("duplicate check name '{}'", c.name));
277            }
278            // A baseline capture is exempt from the asserts-nothing rule: its
279            // job is to CAPTURE a before-value, and demanding an assertion
280            // would force a vacuous one onto every capture.
281            if !c.expect_exit_zero && c.output_contains.is_none() && !c.baseline {
282                issues.push(format!(
283                    "check '{}' asserts nothing (expect_exit_zero=false and no output_contains)",
284                    c.name
285                ));
286            }
287            if c.baseline && c.differential.is_some() {
288                issues.push(format!(
289                    "check '{}' is both a baseline capture and a differential — a capture is \
290                     the before-value, it cannot also diff against one; split it into two \
291                     checks",
292                    c.name
293                ));
294            }
295            if let Some(diff) = &c.differential {
296                // The referenced capture must exist, be marked baseline, and be
297                // declared BEFORE this check — captures accumulate in
298                // declaration order during the baseline pass.
299                let target = self
300                    .checks
301                    .iter()
302                    .position(|t| t.name == diff.baseline && t.baseline);
303                match target {
304                    None => issues.push(format!(
305                        "check '{}' diffs against baseline '{}', but no check by that name is \
306                         marked baseline: true",
307                        c.name, diff.baseline
308                    )),
309                    Some(pos) if pos >= i => issues.push(format!(
310                        "check '{}' diffs against baseline '{}', which is declared after it — \
311                         declare the capture first",
312                        c.name, diff.baseline
313                    )),
314                    Some(_) => {}
315                }
316                // Exhaustive on purpose: a new differential kind must state its
317                // validation here or the build fails.
318                match &diff.expect {
319                    DifferentialExpect::Changed | DifferentialExpect::Unchanged => {}
320                    DifferentialExpect::DeltaWithin { min, max } => {
321                        if min.is_none() && max.is_none() {
322                            issues.push(format!(
323                                "check '{}' declares delta_within with no bounds — an unbounded \
324                                 delta asserts nothing; state min, max, or both",
325                                c.name
326                            ));
327                        }
328                        if let (Some(lo), Some(hi)) = (min, max) {
329                            if lo > hi {
330                                issues.push(format!(
331                                    "check '{}' declares delta_within bounds [{lo}, {hi}] with \
332                                     min above max — no delta can satisfy that",
333                                    c.name
334                                ));
335                            }
336                        }
337                    }
338                }
339            }
340        }
341        if !self.checks.is_empty() && self.checks.iter().all(|c| c.baseline) {
342            issues.push(
343                "every check is a baseline capture — nothing evaluates the outcome; add at \
344                 least one non-baseline check"
345                    .to_string(),
346            );
347        }
348        issues
349    }
350
351    /// Repair *cosmetic* naming problems weak local models commonly produce —
352    /// the literal `unique_snake_case_label` placeholder leaking through, an
353    /// empty name, or a duplicate — by assigning deterministic fallback labels
354    /// (`check_1`, `check_2`, …, suffixed on collision).
355    ///
356    /// Dogfooding finding (2026-07-12): a small local model repeatedly echoed
357    /// the schema placeholder as a check name, and `derive_contract`'s bounded
358    /// repair loop couldn't coax a better one out of it in 3 attempts, so the
359    /// entire coder session aborted *before any coding* over a name. A check's
360    /// NAME is cosmetic — the `command` is the trust boundary — so a naming slip
361    /// must not be a hard failure. Substance problems (empty/toolchain-only/
362    /// assertion-less commands) are deliberately left for [`validate`](crate::coder::contract::OutcomeContract::validate) to drive
363    /// the repair loop, since those DO make the contract gate nothing.
364    pub fn repair_cosmetic_names(&mut self) {
365        let mut seen = std::collections::HashSet::new();
366        for i in 0..self.checks.len() {
367            let name = self.checks[i].name.trim().to_string();
368            let base = if name.is_empty() || name == "unique_snake_case_label" {
369                format!("check_{}", i + 1)
370            } else {
371                name
372            };
373            let mut candidate = base.clone();
374            let mut k = 2;
375            while !seen.insert(candidate.clone()) {
376                candidate = format!("{base}_{k}");
377                k += 1;
378            }
379            self.checks[i].name = candidate;
380        }
381    }
382
383    /// Strip a hallucinated absolute-path `cd` prefix from each check command.
384    ///
385    /// Checks run at the worktree root — the runtime sets CWD. But the derivation
386    /// model, trained on Docker-based coding harnesses, sometimes prefixes a check
387    /// with `cd /repo && …` (or another absolute mount that does not exist here).
388    /// That command then dies on `cd: /repo: No such file or directory` for EVERY
389    /// check, so a correctly-solved task self-verifies as failed and the session
390    /// ends "failed" — a false negative (surfaced by the coder A/B on a bytes2human
391    /// fix the coder got right in one iteration). We drop only a *leading*
392    /// `cd <absolute> &&` / `cd <absolute> ;` (the runtime owns CWD, so it is
393    /// redundant at best and wrong at worst); a relative `cd subdir && …` is a
394    /// legitimate intra-repo move and is left untouched.
395    pub fn strip_absolute_cd_prefixes(&mut self) {
396        for check in &mut self.checks {
397            check.command = strip_leading_absolute_cd(&check.command);
398        }
399    }
400
401    /// Drop a trailing output-limiting pipe (`… | tail -20`, `| head -n 50`,
402    /// `| cat`) from each check command.
403    ///
404    /// A shell pipeline exits with the status of its **last** command, so
405    /// `pytest … | tail -20` exits 0 no matter how badly pytest failed. The
406    /// derivation model adds these to keep output short, and thereby makes the
407    /// check *structurally incapable of failing*: `expect_exit_zero` ends up
408    /// asserting that `tail` ran, which it always does. The coder then
409    /// self-verifies green on broken code, reports `needs_approval`, and prints a
410    /// merge command — which is the exact opposite of this runtime's promise that
411    /// success means "a real command exited 0".
412    ///
413    /// Worse, it is self-concealing: a masked check can never report the failure
414    /// that would let the repair loop notice its command is wrong, so the session
415    /// converges instantly on a lie. Surfaced by the coder A/B, where a gpt-5.4
416    /// arm derived `python -m pytest tests/ -x -q 2>&1 | tail -20`, went green in
417    /// 31s without `flask` even importable, and lost every task to the manifest's
418    /// (unpiped) contract.
419    ///
420    /// Only *output filters* are stripped — `tail`/`head`/`cat` exist purely to
421    /// truncate and always succeed. A pipe into `grep` is left alone: its exit
422    /// status is a real assertion ("output contains X"), which is a legitimate
423    /// check the model may intend.
424    pub fn strip_exit_masking_pipes(&mut self) {
425        for check in &mut self.checks {
426            if check.expect_exit_zero {
427                check.command = strip_trailing_output_filter(&check.command);
428            }
429        }
430    }
431
432    /// Render for prompts and CLI display.
433    pub fn render(&self) -> String {
434        let mut out = format!("{}\nChecks:\n", self.description.trim());
435        for c in &self.checks {
436            out.push_str(&format!("- {}: `{}`", c.name, c.command));
437            let mut expects = Vec::new();
438            if c.baseline {
439                expects.push("baseline capture at session start".to_string());
440            }
441            if c.expect_exit_zero {
442                expects.push("exit 0".to_string());
443            }
444            if let Some(s) = &c.output_contains {
445                if let Some(assertion) = s.strip_prefix("$json:") {
446                    expects.push(format!("JSON asserts {assertion}"));
447                } else {
448                    expects.push(format!("output contains {s:?}"));
449                }
450            }
451            if let Some(diff) = &c.differential {
452                // Exhaustive: a new kind must say how it renders.
453                let claim = match &diff.expect {
454                    DifferentialExpect::Changed => "changed".to_string(),
455                    DifferentialExpect::Unchanged => "unchanged".to_string(),
456                    DifferentialExpect::DeltaWithin { min, max } => format!(
457                        "delta within [{}, {}]",
458                        min.map_or("-inf".to_string(), |m| m.to_string()),
459                        max.map_or("+inf".to_string(), |m| m.to_string()),
460                    ),
461                };
462                expects.push(format!("vs baseline '{}': {claim}", diff.baseline));
463            }
464            if !expects.is_empty() {
465                out.push_str(&format!(" (expects {})", expects.join(", ")));
466            }
467            out.push('\n');
468        }
469        out
470    }
471}
472
473/// Drop a single leading `cd <absolute-path> &&` (or `;`) from a shell command,
474/// repeatedly, returning the remainder that runs at the worktree root. A relative
475/// `cd` (not starting with `/`) is preserved — it is a legitimate intra-repo move.
476/// Only the *leading* separator form is handled: an absolute `cd` buried later in
477/// the command is left alone (rare, and rewriting it risks changing semantics).
478/// Commands that exist only to truncate output and therefore always exit 0.
479/// Piping into one of these discards the real command's exit status.
480const OUTPUT_FILTERS: [&str; 3] = ["tail", "head", "cat"];
481
482/// Drop trailing `| tail …` / `| head …` / `| cat` segments, repeatedly, so the
483/// pipeline's exit status is the real command's again. `||` is an or-list, not a
484/// pipe, and pipes inside quotes are not separators — neither is treated as one.
485fn strip_trailing_output_filter(command: &str) -> String {
486    let mut rest = command.trim().to_string();
487    loop {
488        let Some(idx) = last_top_level_pipe(&rest) else {
489            return rest;
490        };
491        let tail_seg = rest[idx + 1..].trim();
492        let head_word = tail_seg.split_whitespace().next().unwrap_or("");
493        if !OUTPUT_FILTERS.contains(&head_word) {
494            return rest;
495        }
496        // Only strip when the segment is *just* the filter and its flags — a
497        // `| tail -5 && something` is not a plain truncation, leave it be.
498        if tail_seg.contains("&&") || tail_seg.contains(';') || tail_seg.contains("||") {
499            return rest;
500        }
501        rest = rest[..idx].trim_end().to_string();
502        if rest.is_empty() {
503            return command.trim().to_string(); // degenerate; leave untouched
504        }
505    }
506}
507
508/// Byte index of the last `|` that is a real pipe separator: not inside quotes,
509/// and not part of a `||`.
510fn last_top_level_pipe(s: &str) -> Option<usize> {
511    let b = s.as_bytes();
512    let (mut sq, mut dq) = (false, false);
513    let mut found = None;
514    let mut i = 0;
515    while i < b.len() {
516        match b[i] {
517            b'\\' => i += 1, // skip escaped char
518            b'\'' if !dq => sq = !sq,
519            b'"' if !sq => dq = !dq,
520            b'|' if !sq && !dq => {
521                if b.get(i + 1) == Some(&b'|') {
522                    i += 1; // `||` — an or-list, not a pipe
523                } else if i > 0 && b[i - 1] == b'|' {
524                    // trailing half of a `||` already consumed
525                } else {
526                    found = Some(i);
527                }
528            }
529            _ => {}
530        }
531        i += 1;
532    }
533    found
534}
535
536fn strip_leading_absolute_cd(command: &str) -> String {
537    let mut rest = command.trim();
538    while let Some(after_cd) = rest.strip_prefix("cd ") {
539        // Find the separator that ends the `cd` clause.
540        let sep = after_cd
541            .find("&&")
542            .map(|i| (i, 2))
543            .into_iter()
544            .chain(after_cd.find(';').map(|i| (i, 1)))
545            .min_by_key(|(i, _)| *i);
546        let Some((idx, sep_len)) = sep else {
547            break;
548        };
549        let path = after_cd[..idx].trim();
550        // Only strip when the cd target is an absolute path (a single token). A
551        // relative target, or a compound like `cd a || b`, is left as-is.
552        if !path.starts_with('/') || path.split_whitespace().count() != 1 {
553            break;
554        }
555        rest = after_cd[idx + sep_len..].trim_start();
556    }
557    rest.to_string()
558}
559
560/// True when a command only probes that a build tool is installed (e.g.
561/// `cargo --version`, `rustc --version`, `node -v`) — it proves nothing about
562/// the task. Conservative by design: it only fires on a bare
563/// `<tool> --version` / `-V` / `--help` / `-v` invocation with no other
564/// subcommand or shell composition, so real checks like `cargo run -- --version`,
565/// `cargo build`, or `cargo test --version-of-something` are never flagged.
566fn is_toolchain_only(command: &str) -> bool {
567    // Any shell composition means it's doing more than a bare version probe.
568    if command.contains("&&")
569        || command.contains("||")
570        || command.contains('|')
571        || command.contains(';')
572        || command.contains('\n')
573    {
574        return false;
575    }
576    let tokens: Vec<&str> = command.split_whitespace().collect();
577    // Expect exactly `<tool> <version-or-help-flag>`. Anything longer (e.g.
578    // `cargo run -- --version`, `cargo build`) has a subcommand and is real.
579    let [tool, flag] = tokens.as_slice() else {
580        return false;
581    };
582    const TOOLS: &[&str] = &[
583        "cargo", "rustc", "rustup", "node", "npm", "npx", "yarn", "pnpm", "python", "python3",
584        "pip", "pip3", "go", "java", "javac", "ruby", "gem", "dotnet", "deno", "bun", "tsc", "gcc",
585        "clang", "make", "cmake",
586    ];
587    const FLAGS: &[&str] = &["--version", "-V", "-v", "--help", "-h", "version"];
588    TOOLS.contains(tool) && FLAGS.contains(flag)
589}
590
591/// Build the contract-derivation prompt. `issues` carries repair feedback from
592/// a prior failed attempt (car-builder pattern).
593fn build_contract_prompt(intent: &str, repo_summary: &str, issues: &[String]) -> String {
594    let mut p = format!(
595        "You are deriving an OUTCOME CONTRACT for a coding task: a small set of shell \
596         commands that objectively verify the task is done. The commands run at the root of a \
597         fresh git checkout of the repository, non-interactively, with no TTY.\n\n\
598         Task intent:\n{intent}\n\n\
599         Repository summary:\n{repo_summary}\n\n\
600         Respond with ONLY a JSON object, no prose, no markdown fences, in this shape:\n\
601         {{\n  \"description\": \"one-sentence definition of done\",\n  \"checks\": [\n    \
602         {{\"name\": \"unique_snake_case_label\", \"command\": \"shell command\", \
603         \"expect_exit_zero\": true, \"output_contains\": null, \"timeout_secs\": 120}}\n  ]\n}}\n\n\
604         Rules:\n\
605         - Commands run at the repository root ALREADY (the runtime sets the working \
606           directory). Do NOT prefix a command with `cd` into an absolute path, and do NOT \
607           assume a specific mount like `/repo`, `/workspace`, or `/app` — those paths do not \
608           exist here and every such command fails before it runs. Write commands relative to \
609           the repo root (e.g. `python -m pytest tests/test_x.py`, not `cd /repo && python …`).\n\
610           A RELATIVE `cd` is different and is often REQUIRED: when the repository \
611           summary places a build system in a subdirectory, run its commands from there \
612           (e.g. `cd car-rs && cargo test -p some-crate`). The prohibition is on absolute \
613           paths and invented mounts, not on `cd` itself.\n\
614           Do NOT pipe a check into `tail`/`head`/`cat` to shorten output: a pipeline exits with \
615           the LAST command's status, so `pytest … | tail -20` always exits 0 and the check can \
616           never fail. The runtime captures full output itself.\n\
617         - `timeout_secs` must fit the command on a COLD checkout, where nothing is \
618           cached. 120 (the shape example above) suits a fast script or a single unit \
619           test. A compiled-language build or test suite — cargo, go, gradle, swift, \
620           cmake — routinely needs 900–3000. A check killed at its timeout is reported \
621           as a FAILURE, so an under-sized timeout makes the contract permanently red no \
622           matter what the code does; a check that finishes early costs nothing. Size it \
623           generously.\n\
624         - 1 to 5 checks. Each must verify THE TASK ITSELF, not just that the toolchain works \
625           (e.g. `rustc --version` or `cargo --version` prove nothing about the change).\n\
626         - At least one check should exercise the actual new behaviour the intent describes \
627           (run the program/test that the change affects).\n\
628         - For a \"make the failing tests pass\" task, verify by running the failing test's \
629           own FILE (e.g. `python -m pytest tests/test_x.py`), NOT a bespoke reproduction \
630           snippet and NOT a narrow `-k` filter — a hand-written snippet or a guessed filter \
631           routinely passes while the real failing test is untouched, so the session reports \
632           done on an incomplete fix. If specific failing tests are listed below, name them \
633           explicitly. Do NOT run the whole suite (`pytest tests/`): it may contain unrelated \
634           pre-existing failures that your change is not responsible for.\n\
635         - `name` must be a real, descriptive snake_case label unique within the contract — \
636           never the literal placeholder `unique_snake_case_label`.\n\
637         - Every command must run non-interactively and deterministically (no prompts, no \
638           watchers, no servers that don't exit). Use the repo's own build/test commands when \
639           the summary reveals them — a build that must compile the change is a strong check.\n\
640         - `expect_exit_zero: true` (the default) is usually enough. Only set `output_contains` \
641           to a substring you are CERTAIN will appear verbatim in stdout/stderr; if unsure, \
642           leave it null. Do NOT invent example output or placeholder values.\n\
643         - Never use git push, network access, sudo, or anything destructive outside the \
644           checkout. Timeouts are in seconds; keep them realistic for a build.\n"
645    );
646    if !issues.is_empty() {
647        p.push_str("\nYour previous attempt FAILED validation with these issues — fix them:\n");
648        for i in issues {
649            p.push_str(&format!("- {i}\n"));
650        }
651    }
652    p
653}
654
655/// Extract the first JSON object from model output, tolerating code fences and
656/// surrounding prose.
657pub(crate) fn extract_json_object(text: &str) -> Result<Value, String> {
658    let start = text.find('{').ok_or("no JSON object found in output")?;
659    let end = text.rfind('}').ok_or("no closing brace found in output")?;
660    if end < start {
661        return Err("malformed JSON object in output".to_string());
662    }
663    serde_json::from_str(&text[start..=end]).map_err(|e| format!("invalid JSON: {e}"))
664}
665
666/// Whether the intent is a "the tests fail, make them pass" task — the case
667/// where the contract must be grounded in the *actually failing* tests rather
668/// than guessed. Deliberately narrow: the observe-then-derive path runs the test
669/// suite, so it only fires when the intent clearly asks for it.
670pub fn intent_targets_tests(intent: &str) -> bool {
671    let i = intent.to_ascii_lowercase();
672    let mentions_tests = i.contains("test");
673    let mentions_failure = [
674        "fail",
675        "failing",
676        "broken",
677        "passing",
678        "pass the",
679        "make the tests",
680    ]
681    .iter()
682    .any(|k| i.contains(k));
683    mentions_tests && mentions_failure
684}
685
686/// Parse pytest's short-summary `FAILED` lines into node ids
687/// (`tests/test_x.py::test_name`). Best-effort and format-tolerant: the line is
688/// `FAILED <node id> - <reason>`, so the second whitespace token is the id.
689/// Deduplicated, order-preserving. Anything unrecognized yields nothing — the
690/// caller treats an empty result as "learned nothing", not "no failures".
691///
692/// **`ERROR` lines are deliberately excluded.** A pytest `ERROR` is a collection/
693/// setup failure — the module couldn't even be imported (a stdlib API removed in
694/// a newer Python, a missing dep, an unsupported kwarg) — which is *environment
695/// drift*, never the behavioural bug the intent describes, and never what the
696/// task's own contract targets (the extractor scopes to tests that flip
697/// fail→pass under the fix, i.e. `FAILED`s). Grounding on an `ERROR` would import
698/// an unfixable check into the coder's self-contract and burn its whole budget
699/// on drift it can't resolve (the #7 false-negative). Verified live: grounding on
700/// all failures pulled `test_instance_config.py`'s `pkgutil.get_loader` collection
701/// error (gone in 3.14) into the contract; `FAILED`-only drops it and keeps the
702/// real `AssertionError` bug.
703pub fn parse_test_failures(output: &str) -> Vec<String> {
704    let mut seen = std::collections::HashSet::new();
705    let mut ids = Vec::new();
706    for line in output.lines() {
707        let Some(rest) = line.trim().strip_prefix("FAILED ") else {
708            continue;
709        };
710        let id = rest.split_whitespace().next().unwrap_or("").trim();
711        if id.is_empty() || !id.contains(".py") {
712            continue;
713        }
714        if seen.insert(id.to_string()) {
715            ids.push(id.to_string());
716        }
717    }
718    ids
719}
720
721/// Fold observed failing tests into the repo summary handed to derivation, so
722/// the model's contract is grounded in what actually fails instead of guessed.
723/// Empty input returns the summary unchanged.
724pub fn summary_with_failures(repo_summary: &str, failing: &[String]) -> String {
725    if failing.is_empty() {
726        return repo_summary.to_string();
727    }
728    let list = failing
729        .iter()
730        .map(|f| format!("  - {f}"))
731        .collect::<Vec<_>>()
732        .join("\n");
733    format!(
734        "{repo_summary}\n\nObserved failing tests (the suite was run before you; these node \
735         ids currently FAIL). Your contract MUST verify that the ones your change addresses \
736         now pass — run them by their exact node id or their file:\n{list}"
737    )
738}
739
740/// What one derivation attempt asks of the injected generator: the prompt, plus
741/// whether this attempt must be routed to a DIFFERENT model than the last one.
742///
743/// Derivation is the one place in the coder that needs a raw JSON object back
744/// and parses it strictly. Routing does not know that, so when the preferred
745/// lane is down the adaptive arm can fall back to a capable code model that
746/// reliably wraps or truncates the object — and the repair loop then re-sends
747/// the repair prompt through the same routing, landing on the same ill-suited
748/// model all three attempts (Parslee-ai/car#889).
749pub struct ContractDraftRequest {
750    /// The derivation (or repair) prompt for this attempt.
751    pub prompt: String,
752    /// True when the PREVIOUS attempt returned text derivation could not use as
753    /// JSON at all. The generator must route this attempt AWAY from the model it
754    /// used last: a repair prompt cannot fix a model that will not hold strict
755    /// JSON, and re-asking it just burns the budget (Parslee-ai/car#889).
756    pub rotate_model: bool,
757}
758
759/// Derive a contract from `intent` via the injected `generate` closure, with a
760/// bounded validate→repair loop.
761///
762/// `constraints` are rules the operator stated elsewhere — today, agreed in a
763/// `coder.discuss` conversation and carried through `coder.start
764/// { discussion_id }`. They are already spliced into `repo_summary` for the
765/// drafting model, but a prompt is a request, not a guarantee: measured 1
766/// success in 3 trials, the model simply dropped them. So each is **verified**
767/// against the finished draft here, inside the existing attempt budget, and a
768/// miss re-prompts naming the ungated constraint verbatim. Pass `&[]` when
769/// there are none and this costs nothing.
770///
771/// "Verified" means **gated by a check** — see [`ungated_constraints`]. A
772/// constraint that reached only the `description` is treated exactly like one
773/// that was dropped: it drives the repair loop, and if the budget runs out it
774/// is named in the `NOT VERIFIED BY THIS CONTRACT` disclosure. Reaching the
775/// description is not reaching the contract; the loop can self-verify green
776/// against prose.
777pub async fn derive_contract<F, Fut>(
778    generate: F,
779    intent: &str,
780    repo_summary: &str,
781    max_attempts: u32,
782    constraints: &[String],
783) -> Result<OutcomeContract, String>
784where
785    F: Fn(ContractDraftRequest) -> Fut + Send + Sync,
786    Fut: Future<Output = Result<String, String>> + Send,
787{
788    let max = max_attempts.max(1);
789    let mut issues: Vec<String> = Vec::new();
790    let mut last_err = String::new();
791    // Set only by the JSON-shape failures below, and cleared as soon as an
792    // attempt's output parses — one bad reply must not pin rotation on for the
793    // rest of the budget.
794    let mut rotate_model = false;
795    // The best draft seen so far that was structurally valid but still dropped
796    // a constraint. If the budget runs out we return it with the gap stated
797    // rather than nothing — a contract that gates most of the task beats no
798    // session at all, provided the operator is told what is not covered.
799    let mut best_incomplete: Option<(OutcomeContract, Vec<UngatedConstraint>)> = None;
800
801    for _ in 0..max {
802        let prompt = build_contract_prompt(intent, repo_summary, &issues);
803        let request = ContractDraftRequest {
804            prompt,
805            rotate_model,
806        };
807        let text = match tokio::time::timeout(CONTRACT_GEN_TIMEOUT, generate(request)).await {
808            Ok(Ok(t)) => t,
809            Ok(Err(e)) => {
810                // Transient model/transport failure — retry with the same prompt.
811                // Deliberately does NOT set rotation: the model produced no text
812                // to judge, so there is nothing to hold against it, and the
813                // fallback the router already performs is the right response.
814                last_err = format!("generation failed: {e}");
815                continue;
816            }
817            Err(_) => {
818                // Hung backend — bound it instead of blocking forever (PAR-7170).
819                // No rotation here either: a model that never answered told us
820                // nothing about whether it can hold JSON, and the retry
821                // behaviour below is deliberate.
822                //
823                // Don't blame "no model available": the overwhelmingly common
824                // cause is the opposite — a model WAS selected, and it was one
825                // that had to be downloaded first, so the fetch ate the whole
826                // budget (Parslee-ai/car#638). The old wording sent users to
827                // `car models list`, which cheerfully showed the model as
828                // available, and told them nothing.
829                last_err = format!(
830                    "contract generation timed out after {}s. The selected model may still \
831                     be downloading — a first-use fetch can far exceed this budget. Check \
832                     `car models list` for what is actually on disk, pre-pull with \
833                     `car models pull <id>`, or sign in for a cloud model that needs no \
834                     download.",
835                    CONTRACT_GEN_TIMEOUT.as_secs()
836                );
837                continue;
838            }
839        };
840        let value = match extract_json_object(&text) {
841            Ok(v) => v,
842            Err(e) => {
843                // The model answered with something that is not the JSON object
844                // at all (prose, a truncated object, an unclosed fence). That is
845                // a property of the MODEL, not of this prompt — asking the same
846                // one to "return ONLY the JSON object" is what burned all three
847                // attempts on a fallback lane and killed sessions at zero
848                // iterations (Parslee-ai/car#889). Route the next attempt away
849                // from it and let the repair prompt do its work on a model that
850                // can hold the shape.
851                rotate_model = true;
852                issues = vec![format!(
853                    "output did not parse: {e}. Return ONLY the JSON object."
854                )];
855                last_err = issues.join("; ");
856                continue;
857            }
858        };
859        let mut contract: OutcomeContract = match serde_json::from_value(value) {
860            Ok(c) => c,
861            Err(e) => {
862                // Valid JSON, wrong object — the model did not honour the schema
863                // it was handed verbatim. Same judgement as above: this is the
864                // model failing to follow a structural instruction, so rotate
865                // rather than re-ask.
866                rotate_model = true;
867                issues = vec![format!("JSON did not match the contract schema: {e}")];
868                last_err = issues.join("; ");
869                continue;
870            }
871        };
872        // The output parsed, so whatever model produced it CAN hold the shape.
873        // Clear rotation: from here on the loop is arguing about substance, and
874        // substance is what the repair prompt is good at.
875        rotate_model = false;
876        // Fix cosmetic naming slips (placeholder/empty/duplicate labels) in place
877        // rather than burning a repair attempt — and potentially the whole
878        // session — on them. Substance problems still fall through to validate().
879        contract.repair_cosmetic_names();
880        // Drop any hallucinated `cd /repo && …` prefix the derivation model added:
881        // checks run at the worktree root, and a nonexistent absolute cd fails
882        // every check, self-failing a correctly-solved task.
883        contract.strip_absolute_cd_prefixes();
884        // A `… | tail -20` makes the check exit 0 unconditionally — the coder
885        // would then self-verify green on broken code. Drop the mask.
886        contract.strip_exit_masking_pipes();
887        let problems = contract.validate();
888        if !problems.is_empty() {
889            last_err = problems.join("; ");
890            issues = problems;
891            continue;
892        }
893        // Structurally sound. Now: is each constraint actually GATED by a
894        // check — not merely mentioned in the prose?
895        let ungated = ungated_constraints(&generate, &contract, constraints).await;
896        if ungated.is_empty() {
897            return Ok(contract);
898        }
899        // Keep the BEST draft seen, not the newest: attempt 1 can express two
900        // of three constraints and attempt 2 only the third, and returning the
901        // newest then hands back the weaker contract of the two.
902        if best_incomplete
903            .as_ref()
904            .is_none_or(|(_, prior)| ungated.len() < prior.len())
905        {
906            best_incomplete = Some((contract, ungated.clone()));
907        }
908        // Re-prompt naming exactly what is not gated — a blind redraw would be
909        // as likely to drop it again — and say which of the two failures it is,
910        // because "you never mentioned it" and "you mentioned it but nothing
911        // checks it" need different fixes.
912        issues = ungated
913            .iter()
914            .map(|c| match c.coverage {
915                Coverage::Absent => format!(
916                    "you DROPPED this constraint, which the operator agreed and which is not \
917                     optional: \"{}\". Express it as a CHECK whose command actually verifies \
918                     it. Keep every check you already had.",
919                    c.text
920                ),
921                Coverage::ProseOnly => format!(
922                    "this constraint appears only in `description`, where NOTHING VERIFIES \
923                     IT: \"{}\". A contract's force is its checks — prose gates nothing. Add \
924                     a check whose command fails when the constraint is violated (a grep, a \
925                     test, a diff), and keep every check you already had.",
926                    c.text
927                ),
928            })
929            .collect();
930        last_err = format!(
931            "ungated constraint(s): {}",
932            ungated
933                .iter()
934                .map(|c| c.text.as_str())
935                .collect::<Vec<_>>()
936                .join("; ")
937        );
938    }
939    // Budget spent. A valid draft that leaves a constraint ungated is worth
940    // more than an error, but the operator must never be left believing a
941    // constraint was captured when it was not — so it goes in the description,
942    // which is what the confirmation gate and the merge commit both show.
943    //
944    // This fires for prose-only capture too, and that is the point: a
945    // constraint restated in `description` with no check behind it is exactly
946    // as ungated as one that was dropped, and the operator cannot tell the two
947    // apart by reading. The judge used to accept "stated in the description" as
948    // satisfaction, so this disclosure never fired for the case that most looks
949    // like success.
950    if let Some((mut contract, ungated)) = best_incomplete {
951        contract.description = format!(
952            "{}\n\nNOT VERIFIED BY THIS CONTRACT — these constraints from the discussion are \
953             not gated by any check here, so nothing enforces them (a mention above is not a \
954             check); review them by hand before approving:\n{}",
955            contract.description.trim_end(),
956            ungated
957                .iter()
958                .map(|c| format!("  - {}", c.text))
959                .collect::<Vec<_>>()
960                .join("\n")
961        );
962        return Ok(contract);
963    }
964    Err(format!(
965        "could not derive a valid outcome contract after {max} attempts: {last_err}"
966    ))
967}
968
969/// How a constraint failed to be gated by the drafted contract.
970#[derive(Debug, Clone, Copy, PartialEq, Eq)]
971enum Coverage {
972    /// Not in the contract at all.
973    Absent,
974    /// Stated in `description`, but no check verifies it. As ungated as
975    /// [`Absent`](Coverage::Absent) — and far more likely to be mistaken for
976    /// success, by the model that wrote it and by the operator reading it.
977    ProseOnly,
978}
979
980/// One constraint the drafted contract does not gate, and why.
981#[derive(Debug, Clone)]
982struct UngatedConstraint {
983    text: String,
984    coverage: Coverage,
985}
986
987/// Which of `constraints` the drafted contract does not GATE — i.e. which have
988/// no check that would fail if they were violated.
989///
990/// The distinction this draws is the whole point. The judge used to accept a
991/// constraint as satisfied when it was "stated in the description", and the
992/// repair prompt offered that as an explicit escape hatch — so the model's
993/// cheapest way out was to append a sentence to `description`, the judge
994/// returned nothing missing, and derivation returned `Ok` with **no
995/// disclosure**. A contract's force is its checks: prose in the description
996/// gates nothing, so a constraint that reached only the description has not
997/// reached the contract in any sense the loop or the merge gate can act on.
998/// Two identical end-states were being reported differently depending on which
999/// code path produced them; now both drive the repair loop, and both fire the
1000/// `NOT VERIFIED BY THIS CONTRACT` disclosure if the budget runs out.
1001///
1002/// Judging "does this check verify that rule" is itself model work, so it runs
1003/// through the same injected generation path the draft did — no second seam to
1004/// keep in sync, and tests script it like everything else. Fails OPEN: any
1005/// transport, timeout, or parse problem yields "nothing ungated" rather than
1006/// burning the caller's attempt budget on the judge's flakiness. That is the
1007/// safe direction — the worst case is the pre-existing behaviour.
1008async fn ungated_constraints<F, Fut>(
1009    generate: &F,
1010    contract: &OutcomeContract,
1011    constraints: &[String],
1012) -> Vec<UngatedConstraint>
1013where
1014    F: Fn(ContractDraftRequest) -> Fut + Send + Sync,
1015    Fut: Future<Output = Result<String, String>> + Send,
1016{
1017    if constraints.is_empty() {
1018        return Vec::new();
1019    }
1020    let rendered = constraints
1021        .iter()
1022        .enumerate()
1023        .map(|(i, c)| format!("{}. {c}", i + 1))
1024        .collect::<Vec<_>>()
1025        .join("\n");
1026    let contract_json = serde_json::to_string_pretty(contract).unwrap_or_default();
1027    let prompt = format!(
1028        "A verifiable outcome contract was drafted for a coding task. The operator agreed \
1029         these constraints beforehand. A constraint counts as SATISFIED only when some \
1030         check's `command` would actually FAIL if the constraint were violated. Being \
1031         mentioned in `description` does NOT count — the description is prose and runs \
1032         nothing.\n\n\
1033         CONSTRAINTS\n{rendered}\n\n\
1034         CONTRACT\n{contract_json}\n\n\
1035         Return ONLY a JSON object with the 1-based numbers of the constraints that are NOT \
1036         satisfied, split by which failure it is:\n\
1037         {{\"missing\": [1], \"prose_only\": [2]}}\n\n\
1038         - `missing`: the constraint appears nowhere in the contract.\n\
1039         - `prose_only`: the constraint is stated in `description` (or a check NAME) but no \
1040         check command verifies it.\n\n\
1041         Return both arrays empty if every constraint is verified by a check. Judge \
1042         substance, not wording — a check that genuinely verifies the constraint counts even \
1043         if it uses completely different words. Judge the COMMAND, never the name."
1044    );
1045    // Never rotates: the judge only runs once a draft has already parsed, and it
1046    // fails open anyway — routing it away from a working model would buy nothing.
1047    let request = ContractDraftRequest {
1048        prompt,
1049        rotate_model: false,
1050    };
1051    let text = match tokio::time::timeout(CONTRACT_GEN_TIMEOUT, generate(request)).await {
1052        Ok(Ok(t)) => t,
1053        _ => return Vec::new(),
1054    };
1055    let Ok(value) = extract_json_object(&text) else {
1056        return Vec::new();
1057    };
1058    let indices = |field: &str| -> Vec<usize> {
1059        value
1060            .get(field)
1061            .and_then(Value::as_array)
1062            .map(|a| {
1063                a.iter()
1064                    .filter_map(Value::as_u64)
1065                    .filter(|n| *n >= 1 && (*n as usize) <= constraints.len())
1066                    .map(|n| n as usize - 1)
1067                    .collect()
1068            })
1069            .unwrap_or_default()
1070    };
1071    let absent = indices("missing");
1072    let prose_only = indices("prose_only");
1073    // Report in constraint order, and let `missing` win a duplicate: a judge
1074    // that lists the same constraint twice is telling us the harsher of the two.
1075    (0..constraints.len())
1076        .filter_map(|i| {
1077            let coverage = if absent.contains(&i) {
1078                Coverage::Absent
1079            } else if prose_only.contains(&i) {
1080                Coverage::ProseOnly
1081            } else {
1082                return None;
1083            };
1084            Some(UngatedConstraint {
1085                text: constraints[i].clone(),
1086                coverage,
1087            })
1088        })
1089        .collect()
1090}
1091
1092pub(crate) fn check_assertions(
1093    check: &ContractCheck,
1094    exit_code: Option<i64>,
1095    output: &str,
1096    timed_out: bool,
1097) -> bool {
1098    let exit_ok = !check.expect_exit_zero || exit_code == Some(0);
1099    let output_ok = check
1100        .output_contains
1101        .as_deref()
1102        .map(|assertion| output_assertion_passes(assertion, output))
1103        .unwrap_or(true);
1104    exit_ok && output_ok && !timed_out
1105}
1106
1107fn output_assertion_passes(assertion: &str, output: &str) -> bool {
1108    let Some(expression) = assertion.strip_prefix("$json:") else {
1109        return output.contains(assertion);
1110    };
1111    let Some((pointer, expected)) = expression.split_once('=') else {
1112        return false;
1113    };
1114    let Ok(expected) = serde_json::from_str::<Value>(expected) else {
1115        return false;
1116    };
1117    serde_json::from_str::<Value>(output.trim())
1118        .ok()
1119        .and_then(|value| value.pointer(pointer).cloned())
1120        .is_some_and(|actual| actual == expected)
1121}
1122
1123/// A short, quoted preview of an output for a failure message — enough to see
1124/// what was compared, never the whole capture.
1125fn preview(s: &str) -> String {
1126    const CAP: usize = 120;
1127    let trimmed = s.trim();
1128    if trimmed.len() <= CAP {
1129        format!("{trimmed:?}")
1130    } else {
1131        let cut = trimmed
1132            .char_indices()
1133            .take_while(|(i, _)| *i < CAP)
1134            .last()
1135            .map(|(i, c)| i + c.len_utf8())
1136            .unwrap_or(0);
1137        format!("{:?}…", &trimmed[..cut])
1138    }
1139}
1140
1141/// The first numeric token in an output, for [`DifferentialExpect::DeltaWithin`].
1142///
1143/// Tokens are whitespace-split; each is stripped of surrounding punctuation and
1144/// of thousands-separator commas (`435,594` → `435594`) before the parse, so a
1145/// counter embedded in prose (`"orphaned rows: 435,594"`) is still found. First
1146/// match wins — keep a capture command's output down to the one number that
1147/// matters.
1148fn first_number(s: &str) -> Option<f64> {
1149    for token in s.split_whitespace() {
1150        let cleaned: String = token
1151            .trim_matches(|c: char| !c.is_ascii_digit() && c != '-' && c != '+' && c != '.')
1152            .replace(',', "");
1153        if cleaned.is_empty() {
1154            continue;
1155        }
1156        if let Ok(n) = cleaned.parse::<f64>() {
1157            return Some(n);
1158        }
1159    }
1160    None
1161}
1162
1163/// Decide one differential claim: `after_output` against the named capture.
1164/// `Err` is the failure message that lands in the check's `output_tail`, so it
1165/// has to say what was compared and how it missed — it is what the repair
1166/// prompt and the operator read.
1167fn evaluate_differential(
1168    diff: &DifferentialCheck,
1169    baselines: &BaselineCaptures,
1170    after_output: &str,
1171) -> Result<(), String> {
1172    let Some(capture) = baselines.get(&diff.baseline) else {
1173        return Err(format!(
1174            "baseline '{}' was never captured — baseline checks run once at session start, and \
1175             no capture by that name reached this evaluation",
1176            diff.baseline
1177        ));
1178    };
1179    if !capture.passed {
1180        return Err(format!(
1181            "baseline '{}' failed at capture time (exit code {:?}), so there is no trustworthy \
1182             before-value to compare against",
1183            diff.baseline, capture.exit_code
1184        ));
1185    }
1186    // Both sides compared at the same 4 KiB tail the capture was stored at.
1187    let before = capture.output_tail.trim().to_string();
1188    let after_tail = super::shell_tool::tail(after_output, 4 * 1024);
1189    let after = after_tail.trim();
1190
1191    // Exhaustive — a new differential kind must decide its semantics here.
1192    match &diff.expect {
1193        DifferentialExpect::Changed => {
1194            if before == after {
1195                Err(format!(
1196                    "expected the output to CHANGE from baseline '{}', but it is identical to \
1197                     the captured value ({})",
1198                    diff.baseline,
1199                    preview(&before)
1200                ))
1201            } else {
1202                Ok(())
1203            }
1204        }
1205        DifferentialExpect::Unchanged => {
1206            if before == after {
1207                Ok(())
1208            } else {
1209                Err(format!(
1210                    "expected the output to be UNCHANGED from baseline '{}' (the control-group \
1211                     claim), but it moved: baseline {} vs current {}",
1212                    diff.baseline,
1213                    preview(&before),
1214                    preview(after)
1215                ))
1216            }
1217        }
1218        DifferentialExpect::DeltaWithin { min, max } => {
1219            let b = first_number(&before).ok_or_else(|| {
1220                format!(
1221                    "baseline '{}' captured no numeric value to diff against: {}",
1222                    diff.baseline,
1223                    preview(&before)
1224                )
1225            })?;
1226            let a = first_number(after).ok_or_else(|| {
1227                format!(
1228                    "the check output carries no numeric value to diff: {}",
1229                    preview(after)
1230                )
1231            })?;
1232            let delta = a - b;
1233            let lo_ok = min.is_none_or(|m| delta >= m);
1234            let hi_ok = max.is_none_or(|m| delta <= m);
1235            if lo_ok && hi_ok {
1236                Ok(())
1237            } else {
1238                Err(format!(
1239                    "delta {delta} from baseline '{}' ({b} -> {a}) is outside the allowed \
1240                     bounds [{}, {}]",
1241                    diff.baseline,
1242                    min.map_or("-inf".to_string(), |m| m.to_string()),
1243                    max.map_or("+inf".to_string(), |m| m.to_string()),
1244                ))
1245            }
1246        }
1247    }
1248}
1249
1250/// Run one check through the worktree shell tool. The shared core of
1251/// [`evaluate_contract`] and [`evaluate_contract_baseline`], which differ only
1252/// in whether they narrate. `baselines` supplies the before-values any
1253/// differential on this check compares against.
1254async fn run_check(
1255    check: &ContractCheck,
1256    executor: &WorktreeExecutor,
1257    deadline: Option<&SessionDeadline>,
1258    baselines: &BaselineCaptures,
1259) -> CheckResult {
1260    let started = std::time::Instant::now();
1261    let remaining = deadline.and_then(SessionDeadline::remaining_secs);
1262    // Recorded here rather than derived afterwards: once the check has run the
1263    // session clock has moved on, and `remaining_secs` no longer says what this
1264    // check was actually given.
1265    let ceiling = executor.check_timeout_ceiling();
1266    let clamped = deadline_set_the_timeout(check.timeout_secs, remaining, ceiling);
1267    let timeout = effective_check_timeout(check.timeout_secs, remaining, ceiling);
1268    let outcome = executor
1269        .run_check_shell(&check.command, Some(timeout))
1270        .await;
1271    let duration_ms = started.elapsed().as_millis() as u64;
1272
1273    match outcome {
1274        Ok(v) => {
1275            let exit_code = v.get("exit_code").and_then(Value::as_i64);
1276            let output = v.get("output").and_then(Value::as_str).unwrap_or_default();
1277            let timed_out = v.get("timed_out").and_then(Value::as_bool).unwrap_or(false);
1278            let mut passed = check_assertions(check, exit_code, output, timed_out);
1279            let mut output_tail = super::shell_tool::tail(output, 4 * 1024);
1280            // The differential is decided only when the point-in-time
1281            // assertions held: a nonzero exit already fails the check, and its
1282            // output is not a measurement worth diffing.
1283            if passed {
1284                if let Some(diff) = &check.differential {
1285                    if let Err(msg) = evaluate_differential(diff, baselines, output) {
1286                        passed = false;
1287                        output_tail = format!("{output_tail}\n[differential] {msg}")
1288                            .trim_start()
1289                            .to_string();
1290                    }
1291                }
1292            }
1293            CheckResult {
1294                name: check.name.clone(),
1295                // Unchanged: a starved check is still not a pass. The two
1296                // fields below say WHY it is not, which is a different
1297                // question and the one a scorer needs answered.
1298                passed,
1299                exit_code,
1300                output_tail,
1301                duration_ms,
1302                timed_out,
1303                deadline_clamped: clamped,
1304            }
1305        }
1306        Err(e) => CheckResult {
1307            name: check.name.clone(),
1308            passed: false,
1309            exit_code: None,
1310            output_tail: format!("check failed to run: {e}"),
1311            duration_ms,
1312            // A spawn/policy failure is a verdict the runtime reached itself;
1313            // nothing was killed by a clock.
1314            timed_out: false,
1315            deadline_clamped: clamped,
1316        },
1317    }
1318}
1319
1320/// Run every check through the worktree shell tool and report results.
1321///
1322/// All checks run even after a failure — repair prompts and the UI want the
1323/// full picture, and checks are independent by construction.
1324pub async fn evaluate_contract(
1325    contract: &OutcomeContract,
1326    executor: &WorktreeExecutor,
1327    sink: &EventSink,
1328) -> Vec<CheckResult> {
1329    evaluate_contract_within(contract, executor, sink, None).await
1330}
1331
1332/// [`evaluate_contract`], with the session-start baseline captures that
1333/// differential checks compare against. Callers that hold captures (the coder
1334/// loops) use this; the capture-less signatures delegate here with an empty
1335/// map, under which a differential check FAILS with a "never captured"
1336/// message rather than silently passing — fail closed, never open.
1337pub async fn evaluate_contract_with_baselines(
1338    contract: &OutcomeContract,
1339    executor: &WorktreeExecutor,
1340    sink: &EventSink,
1341    baselines: &BaselineCaptures,
1342) -> Vec<CheckResult> {
1343    evaluate_contract_within_baselines(contract, executor, sink, None, baselines).await
1344}
1345
1346/// The timeout one check may take: its own, never more than the session has
1347/// left on the wall clock.
1348///
1349/// `None` means the caller keeps no session clock and the check's own timeout
1350/// stands. `Some(0)` means the budget is spent; `run_shell` clamps a timeout up
1351/// to at least 1s, so such a check gets one second and reports a timeout rather
1352/// than running to completion outside a budget that is already gone. That is
1353/// the honest answer — a run declared as ten minutes should say it ran out, not
1354/// quietly take three hours.
1355///
1356/// The 1s floor is deliberate, and so is NOT short-circuiting a spent budget
1357/// into "skip every check". A cheap check (`test -f target/x`) finishes inside
1358/// that second and still passes, so a run whose budget expired between the
1359/// loop's last green iteration and the runtime's own gate can still deliver its
1360/// pull request; skipping would forfeit deliverable runs to save one second.
1361/// What the floor costs instead is legibility, and
1362/// [`CheckResult::starved_by_deadline`] is what pays that back — a check killed
1363/// at a clamped timeout is marked as such, so a caller can tell "the run was
1364/// out of time" from "the work is red".
1365pub fn clamp_check_timeout(check_timeout_secs: u64, remaining_secs: Option<u64>) -> u64 {
1366    match remaining_secs {
1367        None => check_timeout_secs,
1368        Some(remaining) => check_timeout_secs.min(remaining),
1369    }
1370}
1371
1372/// Was the timeout this check was killed at set by the SESSION clock, or by the
1373/// check's own ceiling?
1374///
1375/// The comparison has to be against the **effective** ceiling, not the declared
1376/// one, and that is the whole content of this function. Every command the
1377/// contract runs goes through
1378/// [`run_check_shell`](super::shell_tool::WorktreeExecutor::run_check_shell),
1379/// which clamps to the executor's
1380/// [`check_timeout_ceiling`](super::shell_tool::WorktreeExecutor::check_timeout_ceiling)
1381/// — [`MAX_SHELL_TIMEOUT_SECS`](super::shell_tool::MAX_SHELL_TIMEOUT_SECS), 600s,
1382/// unless the operator raised it — so at the default a check declaring
1383/// `timeout_secs: 900` (the workspace-suite example in [`clamp_check_timeout`]'s
1384/// own doc above, and `timeout_secs` carries no validation cap) is killed at
1385/// 600s regardless of what the session has left.
1386///
1387/// Comparing against the declared 900 marked the whole `600 < remaining < 900`
1388/// window as deadline-clamped. A genuine 600s hang in that window then reported
1389/// [`CheckResult::starved_by_deadline`], and `car code-task` booked it as
1390/// `session_wall_exhausted` — a class the orchestrator doc tells callers not to
1391/// score. That is precisely the laundering the two-bit encoding exists to
1392/// prevent: a hang is a defect, not a budget excuse.
1393///
1394/// Walk the three cases with `timeout_secs: 900` at the default `ceiling: 600`:
1395/// `remaining: 500` → killed at 500 by the session → true. `remaining: 700` →
1396/// killed at 600 by the shell ceiling, with 100s of session budget to spare →
1397/// false. `remaining: 3600` → false. Raise the ceiling to 900 and the middle
1398/// case flips to true, which is the point: the check was allowed its full 900s
1399/// and only the session clock cut it short (car#1065).
1400pub(crate) fn deadline_set_the_timeout(
1401    check_timeout_secs: u64,
1402    remaining_secs: Option<u64>,
1403    ceiling_secs: u64,
1404) -> bool {
1405    remaining_secs.is_some_and(|r| r < effective_check_ceiling(check_timeout_secs, ceiling_secs))
1406}
1407
1408/// The ceiling one check actually runs under: its own declared `timeout_secs`,
1409/// capped by the executor's contract-check ceiling.
1410///
1411/// Pulled out as a pure function so the raised-ceiling path is unit-testable
1412/// without sitting through a real ten-minute command.
1413pub(crate) fn effective_check_ceiling(check_timeout_secs: u64, ceiling_secs: u64) -> u64 {
1414    check_timeout_secs.min(ceiling_secs)
1415}
1416
1417/// The number of seconds the check's process is actually given: the whole clamp
1418/// in one place — [`clamp_check_timeout`] against the session's remaining
1419/// budget, then the executor's contract-check ceiling with the shell's own 1s
1420/// floor.
1421///
1422/// `run_check_shell` applies the same ceiling again on the way to the process,
1423/// so this is not the enforcement point; stating the composed arithmetic here
1424/// is what makes the raised-ceiling path checkable in a unit test instead of
1425/// only observable by sitting through a real ten-minute command.
1426pub(crate) fn effective_check_timeout(
1427    check_timeout_secs: u64,
1428    remaining_secs: Option<u64>,
1429    ceiling_secs: u64,
1430) -> u64 {
1431    clamp_check_timeout(check_timeout_secs, remaining_secs).clamp(1, ceiling_secs.max(1))
1432}
1433
1434/// [`evaluate_contract`], bounded by a session deadline.
1435///
1436/// `--max-session-wall-secs` used to bound only the LOOP. The baseline run and
1437/// the runtime's own gate sat outside it, so total process wall time was
1438/// `baseline + wall_secs + gate`, where the first and third terms were bounded
1439/// only by the sum of the per-check `timeout_secs` a `--contract-file` supplies
1440/// — with no cap on check count. Six checks at `timeout_secs: 900` (a
1441/// workspace-wide suite is not an exotic contract) could burn 90 minutes before
1442/// the clock even started and another 90 after it stopped: roughly three hours
1443/// for a run an orchestrator declared as ten minutes, and the orchestrator has
1444/// no way to see it coming.
1445///
1446/// The deadline is consulted PER CHECK rather than once per evaluation, and
1447/// that is what makes the bound real: a single snapshot clamp would give every
1448/// one of six checks the same remaining budget and cap the total at six times
1449/// it. Reading it as each check starts means the budget genuinely shrinks.
1450///
1451/// This does not interrupt a check already running — see [`super::budget`] on
1452/// why mid-flight interruption is deliberately not how this works. It bounds
1453/// what a check is ALLOWED to take, which is the part a caller can promise.
1454pub async fn evaluate_contract_within(
1455    contract: &OutcomeContract,
1456    executor: &WorktreeExecutor,
1457    sink: &EventSink,
1458    deadline: Option<&SessionDeadline>,
1459) -> Vec<CheckResult> {
1460    // Empty captures: a differential check under this signature fails with a
1461    // "never captured" message. Callers that hold the session's captures use
1462    // the `_baselines` variant.
1463    evaluate_contract_within_baselines(contract, executor, sink, deadline, &BaselineCaptures::new())
1464        .await
1465}
1466
1467/// [`evaluate_contract_within`], with baseline captures.
1468///
1469/// A [`ContractCheck::baseline`] capture check is NOT re-run here: it already
1470/// ran at session start, and re-capturing "before" after the work would
1471/// destroy the comparison. Its capture-time result is carried into the results
1472/// (and narrated like any check), so a failed capture keeps the gate red
1473/// instead of vanishing, and both executions of a before/after pair are
1474/// visible in the session's events.
1475pub async fn evaluate_contract_within_baselines(
1476    contract: &OutcomeContract,
1477    executor: &WorktreeExecutor,
1478    sink: &EventSink,
1479    deadline: Option<&SessionDeadline>,
1480    baselines: &BaselineCaptures,
1481) -> Vec<CheckResult> {
1482    let mut results = Vec::with_capacity(contract.checks.len());
1483    for check in &contract.checks {
1484        sink.emit(CoderEventKind::CheckStarted {
1485            name: check.name.clone(),
1486        });
1487        let result = if check.baseline {
1488            baselines.get(&check.name).cloned().unwrap_or(CheckResult {
1489                name: check.name.clone(),
1490                passed: false,
1491                exit_code: None,
1492                output_tail: "baseline check was never captured — the runtime runs baseline \
1493                              checks once at session start, and no capture reached this \
1494                              evaluation"
1495                    .to_string(),
1496                duration_ms: 0,
1497                timed_out: false,
1498                deadline_clamped: false,
1499            })
1500        } else {
1501            run_check(check, executor, deadline, baselines).await
1502        };
1503        sink.emit(CoderEventKind::CheckCompleted {
1504            result: result.clone(),
1505        });
1506        results.push(result);
1507    }
1508    results
1509}
1510
1511/// Evaluate the contract against the **unmodified** worktree, before the first
1512/// edit — the red-green baseline.
1513///
1514/// [`OutcomeContract::validate`] already rejects contracts that gate nothing for
1515/// *structural* reasons (assertion-less checks, toolchain-only no-ops, empty
1516/// commands). What it cannot see is semantic vacuity: a check that is
1517/// well-formed, task-specific, and **already passing before any code is
1518/// written**. Such a check clears validation, becomes the session's trust
1519/// boundary, and then reports done for a session that changed nothing relevant.
1520/// Running the contract once up front is what makes that distinguishable:
1521///
1522/// * a check that **fails** here is verifying something the change must fix;
1523/// * a check that **passes** here is not gating this task.
1524///
1525/// Deliberately silent — no `CheckStarted`/`CheckCompleted`. Those events mean
1526/// "the contract is being evaluated on the work", and a UI replaying them for a
1527/// baseline run would show checks going green before a line was written, which
1528/// is precisely the confusion this exists to remove. The results are surfaced as
1529/// baseline instead, on the confirmation the user already sees.
1530pub async fn evaluate_contract_baseline(
1531    contract: &OutcomeContract,
1532    executor: &WorktreeExecutor,
1533) -> Vec<CheckResult> {
1534    evaluate_contract_baseline_within(contract, executor, None).await
1535}
1536
1537/// [`evaluate_contract_baseline`], bounded by a session deadline. See
1538/// [`evaluate_contract_within`] for why the baseline needs one at all.
1539pub async fn evaluate_contract_baseline_within(
1540    contract: &OutcomeContract,
1541    executor: &WorktreeExecutor,
1542    deadline: Option<&SessionDeadline>,
1543) -> Vec<CheckResult> {
1544    let mut results = Vec::with_capacity(contract.checks.len());
1545    // This pass IS the capture pass: a `baseline: true` check's result is
1546    // recorded as it lands, in declaration order, so a differential check
1547    // later in the same pass compares against the value captured moments
1548    // before. That gives the red-green story its honest baseline reading:
1549    // `changed` and a decreasing `delta_within` are red here (nothing has
1550    // changed yet), while `unchanged` — the control-group claim — is green
1551    // here and must STAY green.
1552    let mut captures = BaselineCaptures::new();
1553    for check in &contract.checks {
1554        let result = run_check(check, executor, deadline, &captures).await;
1555        if check.baseline {
1556            captures.insert(check.name.clone(), result.clone());
1557        }
1558        results.push(result);
1559    }
1560    results
1561}
1562
1563/// Whether a baseline run means the contract gates nothing at all.
1564///
1565/// Only an **all**-green baseline qualifies, and that asymmetry is the load-
1566/// bearing part. Plenty of legitimate checks pass at baseline: a refactor task
1567/// ("keep behavior identical, restructure X") *should* have checks green before
1568/// and after — that is the point of it. So a single passing check is
1569/// information, not a fault, and escalating on one would reproduce the failure
1570/// mode `repair_cosmetic_names` exists to avoid: a session aborting before any
1571/// coding over a contract nit. A contract where *every* check already passes is
1572/// unambiguous — there is nothing for the session to turn red-to-green.
1573///
1574/// Empty is not all-green: a contract with no checks is `validate`'s problem.
1575pub fn baseline_gates_nothing(results: &[CheckResult]) -> bool {
1576    !results.is_empty() && results.iter().all(|r| r.passed)
1577}
1578
1579/// Checks whose COMMAND could not run at all, by name.
1580///
1581/// A check that fails because the code is broken is the point — that is the red
1582/// half of the red-green baseline. A check that fails because its program does
1583/// not exist is a different thing wearing the same exit status: the contract
1584/// can never go green no matter what the session writes, so the run is doomed
1585/// before the first edit and will spend its whole iteration budget discovering
1586/// that.
1587///
1588/// Found on a live run: derivation produced `python -m pytest ...` on a machine
1589/// with only `python3`, and twelve iterations of real inference went into a
1590/// contract that was unsatisfiable from the start. Nothing distinguished it
1591/// from an ordinary red baseline.
1592///
1593/// Exit 127 is the shell's "command not found"; `None` means the runtime could
1594/// not spawn it at all. Both mean the same to a caller: this check is not a
1595/// test of anything yet.
1596pub fn baseline_cannot_run(results: &[CheckResult]) -> Vec<String> {
1597    results
1598        .iter()
1599        .filter(|r| {
1600            // A timeout is NOT "cannot run". The command exists and started; a
1601            // clock killed it. Both cases report `exit_code: None`, so without
1602            // this guard a slow check is reported as a missing program.
1603            //
1604            // Two ways that bites, and the second is the common one:
1605            //
1606            //   - "fix the hang in X" is a real task whose baseline check
1607            //     SHOULD time out and SHOULD pass once the hang is fixed.
1608            //     Refusing it rejects exactly the work the item describes.
1609            //   - `ContractCheck::timeout_secs` defaults to 120s, which does
1610            //     not compile a Rust or Swift workspace on a cold checkout.
1611            //     Measured on this repository:
1612            //
1613            //         {"name":"cargo_check_car_server_core","exit_code":null,
1614            //          "duration_ms":120009,"timed_out":true,
1615            //          "output_tail":"command timed out after 120s and was killed"}
1616            //
1617            //     Reported as "the command does not exist here", that makes an
1618            //     unattended loop permanently refuse every item in a repo whose
1619            //     build outruns the default — and the message sends whoever
1620            //     reads it looking for a missing binary.
1621            !r.timed_out && !r.passed && (r.exit_code.is_none() || r.exit_code == Some(127))
1622        })
1623        .map(|r| r.name.clone())
1624        .collect()
1625}
1626
1627#[cfg(test)]
1628mod unrunnable_tests {
1629    use super::*;
1630
1631    fn result(name: &str, passed: bool, exit_code: Option<i64>) -> CheckResult {
1632        CheckResult {
1633            name: name.into(),
1634            passed,
1635            exit_code,
1636            output_tail: String::new(),
1637            duration_ms: 0,
1638            timed_out: false,
1639            deadline_clamped: false,
1640        }
1641    }
1642
1643    /// A check killed by its own clock reports `exit_code: None`, exactly like
1644    /// a spawn failure — so without an explicit guard the two are the same
1645    /// value and a slow check is reported as a missing program.
1646    ///
1647    /// The measured case, on this repository:
1648    ///
1649    /// ```text
1650    /// {"name":"cargo_check_car_server_core","exit_code":null,
1651    ///  "duration_ms":120009,"timed_out":true,
1652    ///  "output_tail":"command timed out after 120s and was killed"}
1653    /// ```
1654    ///
1655    /// `ContractCheck::timeout_secs` defaults to 120s, which does not compile a
1656    /// Rust workspace cold — so treating this as unrunnable makes the
1657    /// unattended loop permanently refuse every item in such a repository.
1658    #[test]
1659    fn a_timed_out_check_is_not_unrunnable() {
1660        let mut timed_out = result("slow_build", false, None);
1661        timed_out.timed_out = true;
1662        timed_out.duration_ms = 120_009;
1663        assert!(
1664            baseline_cannot_run(&[timed_out]).is_empty(),
1665            "a check killed by a clock is not a missing command"
1666        );
1667    }
1668
1669    /// The guard must key on `timed_out`, not on the absence of an exit code —
1670    /// a genuine spawn or policy failure still has to be caught.
1671    #[test]
1672    fn a_spawn_failure_is_still_unrunnable_alongside_a_timeout() {
1673        let mut timed_out = result("slow_build", false, None);
1674        timed_out.timed_out = true;
1675        assert_eq!(
1676            baseline_cannot_run(&[timed_out, result("never_spawned", false, None)]),
1677            vec!["never_spawned".to_string()]
1678        );
1679    }
1680
1681    #[test]
1682    fn an_ordinary_red_check_is_not_unrunnable() {
1683        // The red half of the red-green baseline is the POINT. Reporting it as
1684        // unrunnable would refuse every contract worth having.
1685        assert!(baseline_cannot_run(&[result("tests", false, Some(1))]).is_empty());
1686    }
1687
1688    #[test]
1689    fn a_missing_command_is_unrunnable() {
1690        // 127 is the shell's "command not found". This is the case that cost a
1691        // live trial twelve iterations of real inference against a contract
1692        // that could not go green.
1693        assert_eq!(
1694            baseline_cannot_run(&[result("tests", false, Some(127))]),
1695            vec!["tests".to_string()]
1696        );
1697    }
1698
1699    #[test]
1700    fn a_check_that_never_spawned_is_unrunnable() {
1701        // `None` means the runtime could not start it — a policy refusal or a
1702        // spawn failure. Same verdict: it is not a test of anything yet.
1703        assert_eq!(
1704            baseline_cannot_run(&[result("tests", false, None)]),
1705            vec!["tests".to_string()]
1706        );
1707    }
1708
1709    #[test]
1710    fn a_passing_check_is_never_unrunnable() {
1711        // Belt and braces: a check that PASSED obviously ran, whatever its
1712        // reported exit code.
1713        assert!(baseline_cannot_run(&[result("tests", true, Some(127))]).is_empty());
1714    }
1715}
1716
1717#[cfg(test)]
1718mod tests {
1719
1720    /// The exact divergence the coder A/B surfaced: derivation guesses which
1721    /// tests prove doneness, and when the guess is a bespoke snippet or a narrow
1722    /// filter it can pass while the real failing test is untouched — self-green,
1723    /// ground-truth-red. These pure helpers ground the guess in the observed
1724    /// pytest failures instead.
1725    #[test]
1726    fn parse_test_failures_pulls_node_ids_from_pytest_summary() {
1727        let out = "=========================== short test summary info ============================
1728FAILED tests/test_basic.py::test_session_using_session_settings - AssertionError
1729FAILED tests/test_reqctx.py::test_environ_for_valid_idna - ValueError: x
1730ERROR tests/test_instance_config.py::test_installed_package_paths[True] - AttributeError
1731FAILED tests/test_basic.py::test_session_using_session_settings - AssertionError
17321 failed in 0.10s";
1733        let ids = parse_test_failures(out);
1734        assert_eq!(
1735            ids,
1736            vec![
1737                "tests/test_basic.py::test_session_using_session_settings".to_string(),
1738                "tests/test_reqctx.py::test_environ_for_valid_idna".to_string(),
1739            ],
1740            "FAILED node ids only, deduped, order-preserved — the ERROR \
1741             (collection/environment drift) is excluded"
1742        );
1743        // A run with no failures learns nothing.
1744        assert!(parse_test_failures("125 passed in 0.12s").is_empty());
1745        // A bare non-file token is not a node id.
1746        assert!(parse_test_failures("FAILED something-weird - boom").is_empty());
1747    }
1748
1749    #[test]
1750    fn intent_targets_tests_fires_only_on_test_fixing_intents() {
1751        assert!(intent_targets_tests(
1752            "In this repository, the tests fail because of a bug. Fix the source so the tests pass."
1753        ));
1754        assert!(intent_targets_tests("make the failing tests pass"));
1755        // Not a test-fixing task: no suite run should be triggered.
1756        assert!(!intent_targets_tests("Add a --json flag to the CLI"));
1757        assert!(!intent_targets_tests("Refactor the parser for clarity"));
1758    }
1759
1760    #[test]
1761    fn summary_with_failures_injects_observed_ids_and_is_a_noop_when_empty() {
1762        let base = "Top-level entries: src, tests";
1763        assert_eq!(summary_with_failures(base, &[]), base);
1764        let with = summary_with_failures(
1765            base,
1766            &["tests/test_basic.py::test_session_using_session_settings".to_string()],
1767        );
1768        assert!(with.contains("Observed failing tests"));
1769        assert!(with.contains("tests/test_basic.py::test_session_using_session_settings"));
1770        assert!(with.starts_with(base));
1771    }
1772
1773    /// A pipeline exits with its LAST command's status, so `pytest … | tail -20`
1774    /// exits 0 however badly pytest failed — the check becomes structurally
1775    /// incapable of failing and the coder self-verifies green on broken code.
1776    /// Surfaced by the coder A/B: a gpt-5.4 arm derived exactly this, went green
1777    /// in 31s with `flask` not even importable, and printed a merge command.
1778    #[test]
1779    fn strips_trailing_output_filters_that_mask_the_exit_code() {
1780        let mut c = OutcomeContract {
1781            description: "tests pass".into(),
1782            checks: vec![
1783                ContractCheck {
1784                    name: "run_full_test_suite".into(),
1785                    command: "python -m pytest tests/ -x -q 2>&1 | tail -20".into(),
1786                    expect_exit_zero: true,
1787                    output_contains: None,
1788                    timeout_secs: 120,
1789                    baseline: false,
1790                    differential: None,
1791                },
1792                ContractCheck {
1793                    name: "chained".into(),
1794                    command: "pytest -q | head -n 50 | tail -5".into(),
1795                    expect_exit_zero: true,
1796                    output_contains: None,
1797                    timeout_secs: 120,
1798                    baseline: false,
1799                    differential: None,
1800                },
1801            ],
1802        };
1803        c.strip_exit_masking_pipes();
1804        // `2>&1` is a redirection, not a pipe — it must survive.
1805        assert_eq!(c.checks[0].command, "python -m pytest tests/ -x -q 2>&1");
1806        assert_eq!(c.checks[1].command, "pytest -q");
1807    }
1808
1809    /// `grep`'s exit status IS the assertion ("output contains X"), and `||` is an
1810    /// or-list rather than a pipe. Neither may be rewritten.
1811    #[test]
1812    fn leaves_meaningful_pipes_and_or_lists_alone() {
1813        let keep = [
1814            "pytest -q | grep -q PASSED",
1815            "cmd || echo fallback",
1816            "python -c \"print('a|b')\"",
1817            "pytest -q",
1818        ];
1819        for cmd in keep {
1820            let mut c = OutcomeContract {
1821                description: "d".into(),
1822                checks: vec![ContractCheck {
1823                    name: "k".into(),
1824                    command: cmd.into(),
1825                    expect_exit_zero: true,
1826                    output_contains: None,
1827                    timeout_secs: 120,
1828                    baseline: false,
1829                    differential: None,
1830                }],
1831            };
1832            c.strip_exit_masking_pipes();
1833            assert_eq!(c.checks[0].command, cmd, "must not rewrite: {cmd}");
1834        }
1835    }
1836    use super::*;
1837    use std::sync::atomic::{AtomicUsize, Ordering};
1838
1839    const VALID: &str = r#"{
1840        "description": "file exists",
1841        "checks": [{"name": "exists", "command": "test -f x.txt"}]
1842    }"#;
1843
1844    #[test]
1845    fn prompt_steers_toward_verifying_the_task_and_real_labels() {
1846        let p = build_contract_prompt(
1847            "add a --version flag",
1848            "Top-level entries: Cargo.toml, src\nBuild systems detected: Rust (cargo)",
1849            &[],
1850        );
1851        // Carries the task and repo orientation.
1852        assert!(p.contains("add a --version flag"));
1853        assert!(p.contains("Rust (cargo)"));
1854        // Steers away from toolchain-only checks and placeholder labels.
1855        assert!(p.contains("verify THE TASK ITSELF"));
1856        assert!(
1857            p.contains("rustc --version"),
1858            "names the toolchain-only anti-pattern"
1859        );
1860        assert!(p.contains("never the literal placeholder"));
1861        // Guards the common small-model failure modes.
1862        assert!(p.contains("non-interactively"));
1863        assert!(
1864            p.contains("CERTAIN will appear"),
1865            "output_contains caution present"
1866        );
1867        assert!(p.contains("no markdown fences"));
1868    }
1869
1870    #[test]
1871    fn repair_prompt_appends_prior_issues() {
1872        let p = build_contract_prompt("t", "r", &["check 'a' has an empty command".into()]);
1873        assert!(p.contains("FAILED validation"));
1874        assert!(p.contains("empty command"));
1875    }
1876
1877    #[tokio::test]
1878    async fn derives_on_first_valid_attempt() {
1879        let c = derive_contract(
1880            |_r| async { Ok::<_, String>(VALID.into()) },
1881            "make x",
1882            "repo",
1883            3,
1884            &[],
1885        )
1886        .await
1887        .unwrap();
1888        assert_eq!(c.checks.len(), 1);
1889        assert!(c.checks[0].expect_exit_zero, "default applies");
1890        assert_eq!(c.checks[0].timeout_secs, 120);
1891    }
1892
1893    #[tokio::test(start_paused = true)]
1894    async fn times_out_when_generation_hangs() {
1895        // A hung inference backend (no usable model — PAR-7169/7264) must not make
1896        // derivation block forever; it should fail fast with an actionable error
1897        // (PAR-7170). With the clock paused the 120s timeout fires via virtual
1898        // time, so this test is instant rather than taking two minutes.
1899        let err = derive_contract(
1900            |_r| async {
1901                tokio::time::sleep(std::time::Duration::from_secs(10_000)).await;
1902                Ok::<_, String>(VALID.into())
1903            },
1904            "make x",
1905            "repo",
1906            1,
1907            &[],
1908        )
1909        .await
1910        .unwrap_err();
1911        assert!(
1912            err.contains("timed out"),
1913            "expected timeout error, got: {err}"
1914        );
1915    }
1916
1917    #[tokio::test]
1918    async fn repairs_fenced_and_chatty_output() {
1919        let fenced = format!("Sure! Here is the contract:\n```json\n{VALID}\n```");
1920        let c = derive_contract(
1921            |_r| {
1922                let text = fenced.clone();
1923                async move { Ok::<_, String>(text) }
1924            },
1925            "x",
1926            "r",
1927            3,
1928            &[],
1929        )
1930        .await
1931        .unwrap();
1932        assert_eq!(c.checks[0].name, "exists");
1933    }
1934
1935    #[tokio::test]
1936    async fn invalid_then_repaired() {
1937        let calls = AtomicUsize::new(0);
1938        let c = derive_contract(
1939            |req: ContractDraftRequest| {
1940                let prompt = req.prompt;
1941                let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
1942                async move {
1943                    if n == 1 {
1944                        Ok::<_, String>(r#"{"description": "no checks", "checks": []}"#.into())
1945                    } else {
1946                        assert!(
1947                            prompt.contains("FAILED validation"),
1948                            "repair prompt carries issues"
1949                        );
1950                        Ok(VALID.into())
1951                    }
1952                }
1953            },
1954            "x",
1955            "r",
1956            3,
1957            &[],
1958        )
1959        .await
1960        .unwrap();
1961        assert_eq!(c.checks.len(), 1);
1962    }
1963
1964    #[tokio::test]
1965    async fn gives_up_with_error_after_max() {
1966        let err = derive_contract(
1967            |_r| async { Ok::<_, String>("not json at all".into()) },
1968            "x",
1969            "r",
1970            2,
1971            &[],
1972        )
1973        .await
1974        .unwrap_err();
1975        assert!(err.contains("after 2 attempts"), "{err}");
1976    }
1977
1978    // --- Model rotation on JSON-shape failure (Parslee-ai/car#889) ----------
1979
1980    /// Record the `rotate_model` flag of every attempt, so a test can assert
1981    /// exactly which attempts asked routing for a different model.
1982    fn rotation_recorder() -> std::sync::Arc<std::sync::Mutex<Vec<bool>>> {
1983        std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))
1984    }
1985
1986    /// The live failure: when the preferred lane is down, routing falls back to
1987    /// a capable code model that will not hold strict JSON, and the repair
1988    /// prompt goes back through the SAME routing — so all three attempts land on
1989    /// the same model and the session dies at zero iterations. A model that
1990    /// cannot return the object is not a prompt problem; the next attempt must
1991    /// be routed away from it.
1992    #[tokio::test]
1993    async fn rotates_model_after_unparseable_output() {
1994        let rotations = rotation_recorder();
1995        let seen = rotations.clone();
1996        let calls = AtomicUsize::new(0);
1997        let c = derive_contract(
1998            move |req: ContractDraftRequest| {
1999                seen.lock().unwrap().push(req.rotate_model);
2000                let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
2001                async move {
2002                    if n == 1 {
2003                        // The 2026-08-11 shape verbatim: chatty preamble and a
2004                        // truncated object, so there is no closing brace.
2005                        Ok::<_, String>(
2006                            "Sure! Here is the outcome contract:\n\
2007                             {\"description\": \"tests pass\", \"checks\": ["
2008                                .to_string(),
2009                        )
2010                    } else {
2011                        Ok(VALID.into())
2012                    }
2013                }
2014            },
2015            "x",
2016            "r",
2017            3,
2018            &[],
2019        )
2020        .await
2021        .unwrap();
2022        assert_eq!(c.checks.len(), 1);
2023        assert_eq!(
2024            *rotations.lock().unwrap(),
2025            vec![false, true],
2026            "only the attempt AFTER the unusable reply asks routing to rotate"
2027        );
2028    }
2029
2030    /// Valid JSON, wrong object. The model honoured "return JSON" but not the
2031    /// schema it was handed verbatim — still a structural failure of the model,
2032    /// so the retry gets a different one rather than the same one again.
2033    #[tokio::test]
2034    async fn rotates_model_after_output_that_is_json_but_not_a_contract() {
2035        let rotations = rotation_recorder();
2036        let seen = rotations.clone();
2037        let calls = AtomicUsize::new(0);
2038        let c = derive_contract(
2039            move |req: ContractDraftRequest| {
2040                seen.lock().unwrap().push(req.rotate_model);
2041                let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
2042                async move {
2043                    if n == 1 {
2044                        Ok::<_, String>(
2045                            r#"{"result": "ok", "steps": ["run the tests"]}"#.to_string(),
2046                        )
2047                    } else {
2048                        Ok(VALID.into())
2049                    }
2050                }
2051            },
2052            "x",
2053            "r",
2054            3,
2055            &[],
2056        )
2057        .await
2058        .unwrap();
2059        assert_eq!(c.checks.len(), 1);
2060        assert_eq!(
2061            *rotations.lock().unwrap(),
2062            vec![false, true],
2063            "a schema mismatch is a JSON-shape failure and rotates too"
2064        );
2065    }
2066
2067    /// A validation problem is SUBSTANCE — the model returned exactly the right
2068    /// shape and merely drafted a bad contract. The repair prompt genuinely
2069    /// fixes that on the same model, and rotating would throw away the one
2070    /// model we have just proven can hold the format.
2071    #[tokio::test]
2072    async fn validation_failure_does_not_rotate_model() {
2073        let rotations = rotation_recorder();
2074        let seen = rotations.clone();
2075        let calls = AtomicUsize::new(0);
2076        let c = derive_contract(
2077            move |req: ContractDraftRequest| {
2078                seen.lock().unwrap().push(req.rotate_model);
2079                let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
2080                async move {
2081                    if n == 1 {
2082                        Ok::<_, String>(r#"{"description": "no checks", "checks": []}"#.to_string())
2083                    } else {
2084                        Ok(VALID.into())
2085                    }
2086                }
2087            },
2088            "x",
2089            "r",
2090            3,
2091            &[],
2092        )
2093        .await
2094        .unwrap();
2095        assert_eq!(c.checks.len(), 1);
2096        assert_eq!(
2097            *rotations.lock().unwrap(),
2098            vec![false, false],
2099            "a contract that parsed but failed validate() must stay on its model"
2100        );
2101    }
2102
2103    /// One unusable reply must not pin rotation on for the rest of the budget:
2104    /// once an attempt's output parses, the model has proven it can hold the
2105    /// shape, and any later repair is about substance again.
2106    #[tokio::test]
2107    async fn rotation_clears_once_an_attempt_parses() {
2108        let rotations = rotation_recorder();
2109        let seen = rotations.clone();
2110        let calls = AtomicUsize::new(0);
2111        let c = derive_contract(
2112            move |req: ContractDraftRequest| {
2113                seen.lock().unwrap().push(req.rotate_model);
2114                let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
2115                async move {
2116                    match n {
2117                        // Unusable as JSON — attempt 2 must rotate.
2118                        1 => Ok::<_, String>("I'd be happy to help!".to_string()),
2119                        // Parses, but gates nothing — substance, so attempt 3
2120                        // stays where it is.
2121                        2 => Ok(r#"{"description": "no checks", "checks": []}"#.to_string()),
2122                        _ => Ok(VALID.into()),
2123                    }
2124                }
2125            },
2126            "x",
2127            "r",
2128            3,
2129            &[],
2130        )
2131        .await
2132        .unwrap();
2133        assert_eq!(c.checks.len(), 1);
2134        assert_eq!(
2135            *rotations.lock().unwrap(),
2136            vec![false, true, false],
2137            "rotation is set by the shape failure and cleared by the next parse"
2138        );
2139    }
2140
2141    #[test]
2142    fn is_toolchain_only_flags_bare_version_probes_only() {
2143        // Bare version/help probes of build tools — these gate nothing.
2144        for c in [
2145            "cargo --version",
2146            "cargo -V",
2147            "rustc --version",
2148            "node -v",
2149            "npm --version",
2150            "python3 --version",
2151            "go version",
2152            "make --help",
2153        ] {
2154            assert!(is_toolchain_only(c), "should flag `{c}`");
2155        }
2156        // Real checks that exercise the change must NOT be flagged.
2157        for c in [
2158            "cargo build",
2159            "cargo test",
2160            "cargo run -- --version",
2161            "cargo run --release -- --version",
2162            "./target/debug/greeter --version",
2163            "cargo --version && cargo build",
2164            "test -f src/main.rs",
2165            "grep -q version Cargo.toml",
2166            "rustc src/main.rs -o /tmp/x",
2167        ] {
2168            assert!(!is_toolchain_only(c), "should NOT flag `{c}`");
2169        }
2170    }
2171
2172    #[test]
2173    fn validate_rejects_toolchain_only_and_placeholder_name() {
2174        let c = OutcomeContract {
2175            description: "d".into(),
2176            checks: vec![ContractCheck {
2177                // The literal placeholder leaking through, paired with a
2178                // toolchain-only command — both seen live from a 1.7B model.
2179                name: "unique_snake_case_label".into(),
2180                command: "cargo --version".into(),
2181                expect_exit_zero: true,
2182                output_contains: None,
2183                timeout_secs: 120,
2184                baseline: false,
2185                differential: None,
2186            }],
2187        };
2188        let issues = c.validate();
2189        assert!(
2190            issues.iter().any(|i| i.contains("placeholder name")),
2191            "{issues:?}"
2192        );
2193        assert!(
2194            issues.iter().any(|i| i.contains("toolchain-only no-op")),
2195            "{issues:?}"
2196        );
2197    }
2198
2199    #[test]
2200    fn repair_cosmetic_names_fixes_placeholder_empty_and_duplicates() {
2201        let mk = |name: &str, cmd: &str| ContractCheck {
2202            name: name.into(),
2203            command: cmd.into(),
2204            expect_exit_zero: true,
2205            output_contains: None,
2206            timeout_secs: 60,
2207            baseline: false,
2208            differential: None,
2209        };
2210        let mut c = OutcomeContract {
2211            description: "d".into(),
2212            checks: vec![
2213                mk("unique_snake_case_label", "pytest a"),
2214                mk("", "pytest b"),
2215                mk("run_tests", "pytest c"),
2216                mk("run_tests", "pytest d"),
2217            ],
2218        };
2219        c.repair_cosmetic_names();
2220        let names: Vec<&str> = c.checks.iter().map(|x| x.name.as_str()).collect();
2221        assert_eq!(
2222            names,
2223            vec!["check_1", "check_2", "run_tests", "run_tests_2"]
2224        );
2225        assert_eq!(c.checks[0].command, "pytest a");
2226        assert!(c.validate().is_empty(), "{:?}", c.validate());
2227    }
2228
2229    #[test]
2230    fn strip_absolute_cd_prefixes_drops_repo_but_keeps_relative_and_body() {
2231        let mk = |cmd: &str| ContractCheck {
2232            name: "c".into(),
2233            command: cmd.into(),
2234            expect_exit_zero: true,
2235            output_contains: None,
2236            timeout_secs: 60,
2237            baseline: false,
2238            differential: None,
2239        };
2240        let mut c = OutcomeContract {
2241            description: "d".into(),
2242            checks: vec![
2243                // The exact hallucination the A/B surfaced.
2244                mk("cd /repo && python -m pytest tests/ -v 2>&1"),
2245                // Semicolon separator + absolute path.
2246                mk("cd /workspace ; ./run.sh"),
2247                // A relative cd is a legitimate intra-repo move — keep it.
2248                mk("cd subpkg && cargo test"),
2249                // No cd — untouched.
2250                mk("python -m pytest -q tests/test_x.py"),
2251                // Absolute cd nested later (not leading) — left alone.
2252                mk("echo hi && cd /repo && pytest"),
2253            ],
2254        };
2255        c.strip_absolute_cd_prefixes();
2256        let cmds: Vec<&str> = c.checks.iter().map(|x| x.command.as_str()).collect();
2257        assert_eq!(
2258            cmds,
2259            vec![
2260                "python -m pytest tests/ -v 2>&1",
2261                "./run.sh",
2262                "cd subpkg && cargo test",
2263                "python -m pytest -q tests/test_x.py",
2264                "echo hi && cd /repo && pytest",
2265            ]
2266        );
2267    }
2268
2269    #[tokio::test]
2270    async fn derive_strips_hallucinated_repo_cd_first_try() {
2271        // A model that returns a valid contract but prefixes the check with the
2272        // nonexistent `/repo` mount must not need a repair round — the derived
2273        // contract comes back runnable at the worktree root.
2274        let with_repo_cd = r#"{"description":"tests pass","checks":[
2275            {"name":"run_tests","command":"cd /repo && python -m pytest -q tests/test_x.py"}]}"#;
2276        let c = derive_contract(
2277            |_r: ContractDraftRequest| async move { Ok::<_, String>(with_repo_cd.into()) },
2278            "fix the bug so pytest passes",
2279            "Python",
2280            3,
2281            &[],
2282        )
2283        .await
2284        .unwrap();
2285        assert_eq!(
2286            c.checks[0].command, "python -m pytest -q tests/test_x.py",
2287            "the hallucinated `cd /repo &&` prefix must be stripped"
2288        );
2289    }
2290
2291    #[tokio::test]
2292    async fn derive_succeeds_first_try_when_model_only_leaves_placeholder_name() {
2293        let calls = AtomicUsize::new(0);
2294        let placeholder_named = r#"{"description":"tests pass","checks":[
2295            {"name":"unique_snake_case_label","command":"python3 -m pytest -q"}]}"#;
2296        let c = derive_contract(
2297            |_r: ContractDraftRequest| {
2298                calls.fetch_add(1, Ordering::SeqCst);
2299                async move { Ok::<_, String>(placeholder_named.into()) }
2300            },
2301            "fix the bug so pytest passes",
2302            "Python",
2303            3,
2304            &[],
2305        )
2306        .await
2307        .unwrap();
2308        assert_eq!(calls.load(Ordering::SeqCst), 1, "no repair attempt needed");
2309        assert_eq!(c.checks[0].name, "check_1");
2310        assert_eq!(c.checks[0].command, "python3 -m pytest -q");
2311    }
2312
2313    #[tokio::test]
2314    async fn derive_repairs_a_toolchain_only_first_attempt() {
2315        let calls = AtomicUsize::new(0);
2316        let toolchain_only = r#"{"description":"v","checks":[
2317            {"name":"unique_snake_case_label","command":"cargo --version"}]}"#;
2318        let real = r#"{"description":"v","checks":[
2319            {"name":"version_flag_prints","command":"cargo run -- --version"}]}"#;
2320        let c = derive_contract(
2321            |req: ContractDraftRequest| {
2322                let prompt = req.prompt;
2323                let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
2324                async move {
2325                    if n == 1 {
2326                        Ok::<_, String>(toolchain_only.into())
2327                    } else {
2328                        // Repair prompt carries the SUBSTANCE rejection
2329                        // (toolchain-only). The placeholder name was auto-repaired
2330                        // by repair_cosmetic_names before validate, so a naming
2331                        // slip never burns a repair attempt or reaches the model.
2332                        assert!(prompt.contains("toolchain-only no-op"), "{prompt}");
2333                        assert!(!prompt.contains("placeholder name"), "{prompt}");
2334                        Ok(real.into())
2335                    }
2336                }
2337            },
2338            "add a --version flag",
2339            "Rust (cargo)",
2340            3,
2341            &[],
2342        )
2343        .await
2344        .unwrap();
2345        assert_eq!(c.checks[0].command, "cargo run -- --version");
2346        assert_eq!(calls.load(Ordering::SeqCst), 2, "took exactly one repair");
2347    }
2348
2349    #[test]
2350    fn validate_catches_empty_and_duplicate_and_assertless() {
2351        let c = OutcomeContract {
2352            description: "d".into(),
2353            checks: vec![
2354                ContractCheck {
2355                    name: "a".into(),
2356                    command: "true".into(),
2357                    expect_exit_zero: false,
2358                    output_contains: None,
2359                    timeout_secs: 5,
2360                    baseline: false,
2361                    differential: None,
2362                },
2363                ContractCheck {
2364                    name: "a".into(),
2365                    command: "".into(),
2366                    expect_exit_zero: true,
2367                    output_contains: None,
2368                    timeout_secs: 5,
2369                    baseline: false,
2370                    differential: None,
2371                },
2372            ],
2373        };
2374        let issues = c.validate();
2375        assert!(issues.iter().any(|i| i.contains("asserts nothing")));
2376        assert!(issues.iter().any(|i| i.contains("empty command")));
2377        assert!(issues.iter().any(|i| i.contains("duplicate")));
2378    }
2379
2380    #[tokio::test]
2381    async fn evaluate_passes_and_fails_checks_in_a_real_dir() {
2382        let dir = tempfile::tempdir().unwrap();
2383        std::fs::write(dir.path().join("present.txt"), "hello needle").unwrap();
2384        let exec = WorktreeExecutor::new(dir.path());
2385        let sink = EventSink::test_sink();
2386        let contract = OutcomeContract {
2387            description: "d".into(),
2388            checks: vec![
2389                ContractCheck {
2390                    name: "exists".into(),
2391                    command: crate::coder::test_cmds::file_exists("present.txt"),
2392                    expect_exit_zero: true,
2393                    output_contains: None,
2394                    timeout_secs: 10,
2395                    baseline: false,
2396                    differential: None,
2397                },
2398                ContractCheck {
2399                    name: "content".into(),
2400                    command: crate::coder::test_cmds::cat("present.txt"),
2401                    expect_exit_zero: true,
2402                    output_contains: Some("needle".into()),
2403                    timeout_secs: 10,
2404                    baseline: false,
2405                    differential: None,
2406                },
2407                ContractCheck {
2408                    name: "missing".into(),
2409                    command: crate::coder::test_cmds::file_exists("absent.txt"),
2410                    expect_exit_zero: true,
2411                    output_contains: None,
2412                    timeout_secs: 10,
2413                    baseline: false,
2414                    differential: None,
2415                },
2416            ],
2417        };
2418        let results = evaluate_contract(&contract, &exec, &sink).await;
2419        assert_eq!(results.len(), 3, "all checks run even after a failure");
2420        assert!(results[0].passed);
2421        assert!(results[1].passed);
2422        assert!(!results[2].passed);
2423        assert_eq!(results[2].exit_code, Some(1));
2424    }
2425
2426    #[tokio::test]
2427    async fn evaluate_fails_on_missing_substring() {
2428        let dir = tempfile::tempdir().unwrap();
2429        let exec = WorktreeExecutor::new(dir.path());
2430        let sink = EventSink::test_sink();
2431        let contract = OutcomeContract {
2432            description: "d".into(),
2433            checks: vec![ContractCheck {
2434                name: "needle".into(),
2435                command: "echo haystack".into(),
2436                expect_exit_zero: true,
2437                output_contains: Some("needle".into()),
2438                timeout_secs: 10,
2439                baseline: false,
2440                differential: None,
2441            }],
2442        };
2443        let results = evaluate_contract(&contract, &exec, &sink).await;
2444        assert!(!results[0].passed, "exit 0 but substring missing must fail");
2445        assert_eq!(results[0].exit_code, Some(0));
2446    }
2447
2448    // --- Red-green baseline (car#707) -------------------------------------
2449
2450    fn check(name: &str, command: &str) -> ContractCheck {
2451        ContractCheck {
2452            name: name.into(),
2453            command: command.into(),
2454            expect_exit_zero: true,
2455            output_contains: None,
2456            timeout_secs: 10,
2457            baseline: false,
2458            differential: None,
2459        }
2460    }
2461
2462    // --- The session clock bounds the checks, not just the loop ------------
2463
2464    /// A check may take its own timeout, and never more than the session has
2465    /// left.
2466    #[test]
2467    fn a_check_never_outlives_the_session_budget() {
2468        // No session clock: the check's own timeout stands.
2469        assert_eq!(clamp_check_timeout(900, None), 900);
2470        // Plenty left: unchanged.
2471        assert_eq!(clamp_check_timeout(900, Some(3600)), 900);
2472        // Less left than the check wants: the session wins.
2473        assert_eq!(clamp_check_timeout(900, Some(30)), 30);
2474        // Spent. `run_shell` floors this at 1s, so the check reports a timeout
2475        // instead of running to completion outside a budget already gone.
2476        assert_eq!(clamp_check_timeout(900, Some(0)), 0);
2477    }
2478
2479    /// The baseline run is INSIDE the session clock.
2480    ///
2481    /// `--max-session-wall-secs` used to bound only the loop, so a contract
2482    /// holding checks with long `timeout_secs` could burn its full sum before
2483    /// the clock started — a ten-minute budget buying a three-hour run. The
2484    /// check here would take five seconds on its own timeout; with the session
2485    /// budget exhausted it must be cut off in about one.
2486    #[tokio::test]
2487    async fn an_exhausted_session_budget_cuts_the_baseline_short() {
2488        let dir = tempfile::tempdir().unwrap();
2489        let exec = WorktreeExecutor::new(dir.path());
2490        let contract = OutcomeContract {
2491            description: "d".into(),
2492            checks: vec![ContractCheck {
2493                name: "slow".into(),
2494                command: "sleep 5".into(),
2495                expect_exit_zero: true,
2496                output_contains: None,
2497                timeout_secs: 30,
2498                baseline: false,
2499                differential: None,
2500            }],
2501        };
2502
2503        let spent = SessionDeadline::new(Some(0));
2504        let started = std::time::Instant::now();
2505        let baseline = evaluate_contract_baseline_within(&contract, &exec, Some(&spent)).await;
2506        let elapsed = started.elapsed();
2507
2508        assert!(!baseline[0].passed, "a cut-off check is not a pass");
2509        assert!(
2510            elapsed < std::time::Duration::from_secs(4),
2511            "the baseline ran for {elapsed:?}; a spent session budget must cut it short"
2512        );
2513    }
2514
2515    /// A check the session clock killed says so on the result itself.
2516    ///
2517    /// Without this the gate's red is unreadable: `passed: false` covers both
2518    /// "the tests failed" and "the budget expired before the tests could run",
2519    /// and `car code-task` booked the second as `contract_not_green` — a
2520    /// scorable no-progress round charged to the model for work the loop had
2521    /// already verified green (car#1053).
2522    #[tokio::test]
2523    async fn a_check_starved_by_the_session_clock_is_marked_as_such() {
2524        let dir = tempfile::tempdir().unwrap();
2525        let exec = WorktreeExecutor::new(dir.path());
2526        let slow = ContractCheck {
2527            name: "suite".into(),
2528            command: "sleep 5".into(),
2529            expect_exit_zero: true,
2530            output_contains: None,
2531            // Generous on its own terms: nothing here is the check's fault.
2532            timeout_secs: 900,
2533            baseline: false,
2534            differential: None,
2535        };
2536        let spent = SessionDeadline::new(Some(0));
2537
2538        let r = run_check(&slow, &exec, Some(&spent), &BaselineCaptures::new()).await;
2539
2540        assert!(!r.passed, "a cut-off check is still not a pass");
2541        assert!(r.timed_out, "it was killed at a timeout, not exited");
2542        assert!(
2543            r.deadline_clamped,
2544            "the timeout it died at was the session's leftover budget, not its own 900s"
2545        );
2546        assert!(
2547            r.starved_by_deadline(),
2548            "so this is not a verdict on the work"
2549        );
2550    }
2551
2552    /// And the other half of the pair: a check that blows its OWN timeout is a
2553    /// genuine red. A hang is a defect, and laundering one into a budget excuse
2554    /// is exactly the failure the two-bit encoding exists to prevent.
2555    #[tokio::test]
2556    async fn a_check_that_blows_its_own_timeout_is_not_starved() {
2557        let dir = tempfile::tempdir().unwrap();
2558        let exec = WorktreeExecutor::new(dir.path());
2559        let hang = ContractCheck {
2560            name: "suite".into(),
2561            command: "sleep 5".into(),
2562            expect_exit_zero: true,
2563            output_contains: None,
2564            timeout_secs: 1,
2565            baseline: false,
2566            differential: None,
2567        };
2568        let plenty = SessionDeadline::new(Some(3600));
2569
2570        let r = run_check(&hang, &exec, Some(&plenty), &BaselineCaptures::new()).await;
2571
2572        assert!(!r.passed);
2573        assert!(r.timed_out, "it ran past its own one-second ceiling");
2574        assert!(
2575            !r.deadline_clamped,
2576            "the session had an hour left — nothing was clamped"
2577        );
2578        assert!(
2579            !r.starved_by_deadline(),
2580            "a genuine hang must stay a red verdict"
2581        );
2582    }
2583
2584    /// The half of that pair no test can run: a check is killed at
2585    /// `min(timeout_secs, remaining).clamp(1, MAX_SHELL_TIMEOUT_SECS)`, so a
2586    /// declared `timeout_secs` above 600 is cut by the SHELL ceiling, not by the
2587    /// session — and a 600-second hang is not something a unit test can sit
2588    /// through. So the derivation is pinned directly.
2589    ///
2590    /// The window this closes is real, not theoretical: `timeout_secs` has no
2591    /// validation cap and `clamp_check_timeout`'s own doc uses `900` as the
2592    /// realistic workspace-suite figure, which against the default 3600s session
2593    /// leaves ~300 seconds per run in which a genuine hang would have been
2594    /// laundered into `session_wall_exhausted`.
2595    #[test]
2596    fn the_clamp_flag_is_derived_against_the_shell_ceiling_not_the_declared_one() {
2597        use super::super::shell_tool::MAX_SHELL_TIMEOUT_SECS;
2598        assert_eq!(
2599            MAX_SHELL_TIMEOUT_SECS, 600,
2600            "the cases below are read at 600"
2601        );
2602        let default = MAX_SHELL_TIMEOUT_SECS;
2603
2604        // Session cut it: 500 < 600, so the check really did lose seconds it
2605        // would otherwise have had.
2606        assert!(deadline_set_the_timeout(900, Some(500), default));
2607
2608        // The one that was wrong. 700s of budget left, but the check dies at the
2609        // 600s shell ceiling — the session had time to spare, so this is a hang.
2610        assert!(
2611            !deadline_set_the_timeout(900, Some(700), default),
2612            "600 < remaining < timeout_secs is the shell ceiling cutting the \
2613             check, not the session clock — marking it clamped would let a hang \
2614             report as session_wall_exhausted"
2615        );
2616
2617        // Plenty of budget, nothing clamped.
2618        assert!(!deadline_set_the_timeout(900, Some(3600), default));
2619        // Exactly at the ceiling is not below it.
2620        assert!(!deadline_set_the_timeout(900, Some(600), default));
2621        // Below the check's own sub-ceiling timeout: the session did cut it.
2622        assert!(deadline_set_the_timeout(120, Some(30), default));
2623        assert!(!deadline_set_the_timeout(120, Some(200), default));
2624        // No deadline at all clamps nothing.
2625        assert!(!deadline_set_the_timeout(900, None, default));
2626
2627        // And the case this one exists to complement (car#1065). The operator
2628        // raised the ceiling to 900, so the check really was allowed its full
2629        // declared timeout — 700s of remaining budget is then the session clock
2630        // cutting it and nothing else. That classification was unreachable while
2631        // the ceiling was a constant.
2632        assert!(
2633            deadline_set_the_timeout(900, Some(700), 900),
2634            "with the ceiling raised to the declared timeout, a shorter remaining \
2635             budget is the session clock cutting the check"
2636        );
2637        // Raising it PAST the declared timeout changes nothing: the check's own
2638        // `timeout_secs` is still the binding ceiling.
2639        assert!(!deadline_set_the_timeout(900, Some(950), 3600));
2640        assert!(deadline_set_the_timeout(900, Some(800), 3600));
2641    }
2642
2643    /// The composed two-step clamp, pinned as arithmetic: the session budget
2644    /// first, then the executor's contract-check ceiling with the shell's own 1s
2645    /// floor.
2646    #[test]
2647    fn the_effective_check_timeout_composes_budget_then_ceiling() {
2648        // Default ceiling: a 900s declaration is cut to 600 whatever the budget.
2649        assert_eq!(effective_check_timeout(900, None, 600), 600);
2650        assert_eq!(effective_check_timeout(900, Some(3600), 600), 600);
2651        // A session budget below the ceiling wins.
2652        assert_eq!(effective_check_timeout(900, Some(120), 600), 120);
2653        // Raised ceiling: the declaration finally stands. This is the fix.
2654        assert_eq!(effective_check_timeout(900, Some(3600), 900), 900);
2655        assert_eq!(effective_check_timeout(900, None, 1200), 900);
2656        // A spent budget still gets the 1s floor rather than zero, and a `0`
2657        // ceiling cannot take that floor away either.
2658        assert_eq!(effective_check_timeout(900, Some(0), 600), 1);
2659        assert_eq!(effective_check_timeout(900, None, 0), 1);
2660    }
2661
2662    /// And the ceiling reaches the PROCESS, not only the flag: with a
2663    /// one-second ceiling a `sleep 5` declaring 30s is killed at one second and
2664    /// reports a plain timeout — the session had an hour left, so nothing was
2665    /// deadline-clamped.
2666    ///
2667    /// The cheap direction of the same proof: checking the raised ceiling
2668    /// directly would mean sitting through ten real minutes.
2669    #[tokio::test]
2670    async fn the_check_ceiling_bounds_the_process_not_just_the_flag() {
2671        let dir = tempfile::tempdir().unwrap();
2672        let exec = WorktreeExecutor::new(dir.path()).with_check_timeout_ceiling(1);
2673        assert_eq!(exec.check_timeout_ceiling(), 1);
2674        let slow = ContractCheck {
2675            name: "slow".into(),
2676            command: "sleep 5".into(),
2677            expect_exit_zero: true,
2678            output_contains: None,
2679            timeout_secs: 30,
2680            baseline: false,
2681            differential: None,
2682        };
2683        let plenty = SessionDeadline::new(Some(3600));
2684
2685        let r = run_check(&slow, &exec, Some(&plenty), &BaselineCaptures::new()).await;
2686
2687        assert!(!r.passed);
2688        assert!(
2689            r.timed_out,
2690            "the 1s check ceiling killed it, not the declared 30s"
2691        );
2692        assert!(
2693            !r.deadline_clamped,
2694            "the session had an hour left — the check ceiling cut it"
2695        );
2696        assert!(r.duration_ms < 5_000, "it must not have slept the full 5s");
2697    }
2698
2699    /// A zero from config cannot make every check die instantly: the builder
2700    /// floors the ceiling at one second.
2701    #[test]
2702    fn a_zero_check_ceiling_is_floored_not_honored() {
2703        let dir = tempfile::tempdir().unwrap();
2704        let exec = WorktreeExecutor::new(dir.path()).with_check_timeout_ceiling(0);
2705        assert_eq!(exec.check_timeout_ceiling(), 1);
2706    }
2707
2708    /// The default is unchanged, and the MODEL-facing `shell` tool keeps the
2709    /// advertised 600s ceiling even on an executor whose CHECK ceiling is
2710    /// raised — a slow test gate is the operator's decision about their own
2711    /// repository, not a licence for the model to sit on one command for an
2712    /// hour.
2713    #[test]
2714    fn raising_the_check_ceiling_leaves_the_model_facing_shell_alone() {
2715        use super::super::shell_tool::MAX_SHELL_TIMEOUT_SECS;
2716        let dir = tempfile::tempdir().unwrap();
2717        let exec = WorktreeExecutor::new(dir.path());
2718        assert_eq!(exec.check_timeout_ceiling(), MAX_SHELL_TIMEOUT_SECS);
2719
2720        let raised = WorktreeExecutor::new(dir.path()).with_check_timeout_ceiling(3600);
2721        assert_eq!(raised.check_timeout_ceiling(), 3600);
2722        let shell_def = WorktreeExecutor::tool_defs()
2723            .into_iter()
2724            .find(|d| d["name"] == "shell")
2725            .expect("shell tool is advertised");
2726        assert!(
2727            shell_def["parameters"]["properties"]["timeout_secs"]["description"]
2728                .as_str()
2729                .unwrap()
2730                .contains("max 600"),
2731            "the model-facing description still promises 600"
2732        );
2733    }
2734
2735    /// And an unspent budget leaves the check's own timeout alone — the clamp
2736    /// must not truncate a run that is inside its declared ceiling.
2737    #[tokio::test]
2738    async fn a_healthy_session_budget_does_not_truncate_the_baseline() {
2739        let dir = tempfile::tempdir().unwrap();
2740        let exec = WorktreeExecutor::new(dir.path());
2741        let contract = OutcomeContract {
2742            description: "d".into(),
2743            checks: vec![check("quick", "exit 0")],
2744        };
2745        let plenty = SessionDeadline::new(Some(3600));
2746        let baseline = evaluate_contract_baseline_within(&contract, &exec, Some(&plenty)).await;
2747        assert!(baseline[0].passed);
2748    }
2749
2750    /// The case the baseline exists to catch: every check is well-formed and
2751    /// task-specific enough to clear `validate()`, and every one already passes
2752    /// on an unmodified worktree — so the contract gates nothing for this task.
2753    #[tokio::test]
2754    async fn an_all_green_baseline_is_flagged_as_gating_nothing() {
2755        let dir = tempfile::tempdir().unwrap();
2756        let exec = WorktreeExecutor::new(dir.path());
2757        let contract = OutcomeContract {
2758            description: "d".into(),
2759            checks: vec![check("a", "exit 0"), check("b", "exit 0")],
2760        };
2761
2762        let baseline = evaluate_contract_baseline(&contract, &exec).await;
2763        assert_eq!(baseline.len(), 2);
2764        assert!(baseline.iter().all(|r| r.passed));
2765        assert!(baseline_gates_nothing(&baseline));
2766    }
2767
2768    /// A mixed baseline must NOT be flagged. Checks that pass before the change
2769    /// are ordinary — a refactor's checks are green before and after by design —
2770    /// so escalating on one would abort sessions over a non-fault.
2771    #[tokio::test]
2772    async fn a_mixed_baseline_is_not_flagged() {
2773        let dir = tempfile::tempdir().unwrap();
2774        let exec = WorktreeExecutor::new(dir.path());
2775        let contract = OutcomeContract {
2776            description: "d".into(),
2777            checks: vec![
2778                check("already_green", "exit 0"),
2779                check("must_fix", "exit 1"),
2780            ],
2781        };
2782
2783        let baseline = evaluate_contract_baseline(&contract, &exec).await;
2784        assert!(baseline[0].passed);
2785        assert!(
2786            !baseline[1].passed,
2787            "the red check is what gates the session"
2788        );
2789        assert!(
2790            !baseline_gates_nothing(&baseline),
2791            "one green check among red ones is information, not a fault"
2792        );
2793    }
2794
2795    #[tokio::test]
2796    async fn an_all_red_baseline_is_not_flagged() {
2797        let dir = tempfile::tempdir().unwrap();
2798        let exec = WorktreeExecutor::new(dir.path());
2799        let contract = OutcomeContract {
2800            description: "d".into(),
2801            checks: vec![check("must_fix", "exit 1")],
2802        };
2803        let baseline = evaluate_contract_baseline(&contract, &exec).await;
2804        assert!(!baseline_gates_nothing(&baseline));
2805    }
2806
2807    /// An empty result set is not "all green" — vacuous truth would report a
2808    /// checkless contract as gating nothing *here*, stealing the diagnosis from
2809    /// `validate()`, which owns that case and gives a better message.
2810    #[test]
2811    fn an_empty_baseline_is_not_all_green() {
2812        assert!(!baseline_gates_nothing(&[]));
2813    }
2814
2815    /// The baseline must produce the same verdicts as a narrated evaluation —
2816    /// it is the same checks against the same worktree, differing only in
2817    /// whether it emits events.
2818    #[tokio::test]
2819    async fn baseline_agrees_with_the_narrated_evaluation() {
2820        let dir = tempfile::tempdir().unwrap();
2821        let exec = WorktreeExecutor::new(dir.path());
2822        let sink = EventSink::test_sink();
2823        let contract = OutcomeContract {
2824            description: "d".into(),
2825            checks: vec![check("green", "exit 0"), check("red", "exit 1")],
2826        };
2827
2828        let baseline = evaluate_contract_baseline(&contract, &exec).await;
2829        let narrated = evaluate_contract(&contract, &exec, &sink).await;
2830
2831        let verdicts = |rs: &[CheckResult]| -> Vec<(String, bool)> {
2832            rs.iter().map(|r| (r.name.clone(), r.passed)).collect()
2833        };
2834        assert_eq!(verdicts(&baseline), verdicts(&narrated));
2835    }
2836
2837    /// Outcomes line 44: a constraint stated only in the discussion must reach
2838    /// the drafted contract. The drafting model demonstrably drops them
2839    /// (measured 1 success / 3 trials), so carry-through is VERIFIED inside the
2840    /// existing attempt budget, and a miss re-prompts naming the dropped
2841    /// constraint verbatim rather than redrawing blindly.
2842    #[tokio::test]
2843    async fn a_dropped_constraint_is_repaired_into_the_contract() {
2844        use std::sync::Mutex;
2845        // Turn 1: a draft that ignores the constraint.
2846        // Turn 2: the judge, reporting constraint 1 missing.
2847        // Turn 3: the repaired draft.
2848        // Turn 4: the judge again, now satisfied.
2849        let script = Mutex::new(vec![
2850            r#"{"description":"tests pass","checks":[{"name":"tests","command":"exit 0"}]}"#
2851                .to_string(),
2852            r#"{"missing":[1]}"#.to_string(),
2853            r#"{"description":"tests pass and the public signature is untouched",
2854                "checks":[{"name":"tests","command":"exit 0"},
2855                          {"name":"signature_unchanged","command":"grep -q 'fn add(a: i32, b: i32)' src/lib.rs"}]}"#
2856                .to_string(),
2857            r#"{"missing":[]}"#.to_string(),
2858        ]);
2859        let prompts: Mutex<Vec<String>> = Mutex::new(Vec::new());
2860        let constraint = "The public signature of add() must stay exactly as it is.";
2861
2862        let contract = derive_contract(
2863            |r: ContractDraftRequest| {
2864                prompts.lock().unwrap().push(r.prompt);
2865                let next = script.lock().unwrap().remove(0);
2866                async move { Ok::<_, String>(next) }
2867            },
2868            "make the failing tests pass",
2869            "Top-level entries: src, Cargo.toml",
2870            3,
2871            &[constraint.to_string()],
2872        )
2873        .await
2874        .expect("the repair pass must produce a contract");
2875
2876        // The constraint is now expressed as a real check.
2877        assert!(
2878            contract
2879                .checks
2880                .iter()
2881                .any(|c| c.name == "signature_unchanged"),
2882            "the dropped constraint must be repaired into the contract: {contract:?}"
2883        );
2884        // ...and the repair prompt named it verbatim rather than redrawing blind.
2885        let prompts = prompts.lock().unwrap();
2886        assert!(
2887            prompts[2].contains(constraint) && prompts[2].contains("DROPPED"),
2888            "the retry must name the dropped constraint verbatim: {}",
2889            prompts[2]
2890        );
2891    }
2892
2893    /// When the budget runs out with a constraint still unexpressed, the
2894    /// contract still comes back — but says so. An operator who stated a
2895    /// constraint must never be left believing it was captured when it was not.
2896    #[tokio::test]
2897    async fn an_unexpressible_constraint_is_disclosed_not_dropped() {
2898        use std::sync::Mutex;
2899        let script = Mutex::new(vec![
2900            r#"{"description":"tests pass","checks":[{"name":"tests","command":"exit 0"}]}"#
2901                .to_string(),
2902            r#"{"missing":[1]}"#.to_string(),
2903            r#"{"description":"tests pass","checks":[{"name":"tests","command":"exit 0"}]}"#
2904                .to_string(),
2905            r#"{"missing":[1]}"#.to_string(),
2906        ]);
2907        let constraint = "Get written sign-off from the CFO before merging.";
2908
2909        let contract = derive_contract(
2910            |_r: ContractDraftRequest| {
2911                let next = script.lock().unwrap().remove(0);
2912                async move { Ok::<_, String>(next) }
2913            },
2914            "make the failing tests pass",
2915            "Top-level entries: src",
2916            2,
2917            &[constraint.to_string()],
2918        )
2919        .await
2920        .expect("a valid draft beats no session, provided the gap is stated");
2921
2922        assert!(
2923            contract
2924                .description
2925                .contains("NOT VERIFIED BY THIS CONTRACT")
2926                && contract.description.contains(constraint),
2927            "an unexpressible constraint must be disclosed in the description: {}",
2928            contract.description
2929        );
2930        // The real check survived — disclosure is additive, not a replacement.
2931        assert!(contract.checks.iter().any(|c| c.name == "tests"));
2932    }
2933
2934    /// A constraint that reaches only the `description` has NOT been captured:
2935    /// prose gates nothing, and the loop can self-verify green against it.
2936    ///
2937    /// The judge used to accept "stated in the description" as satisfaction and
2938    /// the repair prompt offered it as an explicit escape hatch, so the model's
2939    /// cheapest move — append a sentence — ended derivation with `Ok` and **no**
2940    /// disclosure. Two identical end-states (constraint present as prose only)
2941    /// were reported differently depending on the code path that produced them.
2942    #[tokio::test]
2943    async fn a_constraint_captured_only_in_prose_fires_the_disclosure() {
2944        use std::sync::Mutex;
2945        let script = Mutex::new(vec![
2946            // 1: a draft that ignores the constraint entirely.
2947            r#"{"description":"tests pass","checks":[{"name":"tests","command":"exit 0"}]}"#
2948                .to_string(),
2949            r#"{"missing":[1],"prose_only":[]}"#.to_string(),
2950            // 2: the cheap way out — the constraint restated in prose, no check.
2951            r#"{"description":"tests pass, and the public signature of add() is unchanged",
2952                "checks":[{"name":"tests","command":"exit 0"}]}"#
2953                .to_string(),
2954            r#"{"missing":[],"prose_only":[1]}"#.to_string(),
2955            // 3: it does it again.
2956            r#"{"description":"tests pass, and the public signature of add() is unchanged",
2957                "checks":[{"name":"tests","command":"exit 0"}]}"#
2958                .to_string(),
2959            r#"{"missing":[],"prose_only":[1]}"#.to_string(),
2960        ]);
2961        let prompts: Mutex<Vec<String>> = Mutex::new(Vec::new());
2962        let constraint = "The public signature of add() must stay exactly as it is.";
2963
2964        let contract = derive_contract(
2965            |r: ContractDraftRequest| {
2966                prompts.lock().unwrap().push(r.prompt);
2967                let next = script.lock().unwrap().remove(0);
2968                async move { Ok::<_, String>(next) }
2969            },
2970            "make the failing tests pass",
2971            "Top-level entries: src",
2972            3,
2973            &[constraint.to_string()],
2974        )
2975        .await
2976        .expect("a valid draft beats no session, provided the gap is stated");
2977
2978        assert!(
2979            contract
2980                .description
2981                .contains("NOT VERIFIED BY THIS CONTRACT")
2982                && contract.description.contains(constraint),
2983            "a prose-only constraint must be disclosed, not passed off as captured: {}",
2984            contract.description
2985        );
2986        assert!(
2987            contract.checks.iter().all(|c| c.name == "tests"),
2988            "nothing here gates the constraint: {contract:?}"
2989        );
2990        // The third draft prompt named the prose failure specifically — "you
2991        // never mentioned it" and "you mentioned it but nothing checks it" need
2992        // different fixes.
2993        let prompts = prompts.lock().unwrap();
2994        assert!(
2995            prompts[4].contains(constraint) && prompts[4].contains("NOTHING VERIFIES IT"),
2996            "the repair must name the prose-only failure: {}",
2997            prompts[4]
2998        );
2999    }
3000
3001    /// When the budget runs out, the draft that covered the MOST constraints
3002    /// comes back — not merely the last one drafted. Attempt 1 covering two of
3003    /// three and attempt 2 covering one used to return attempt 2.
3004    #[tokio::test]
3005    async fn the_disclosed_draft_is_the_best_one_seen_not_the_newest() {
3006        use std::sync::Mutex;
3007        let script = Mutex::new(vec![
3008            // 1: gates the first constraint, drops the second.
3009            r#"{"description":"d","checks":[{"name":"first_gated","command":"exit 0"}]}"#
3010                .to_string(),
3011            r#"{"missing":[2],"prose_only":[]}"#.to_string(),
3012            // 2: a worse draft — it now gates neither.
3013            r#"{"description":"d","checks":[{"name":"gates_neither","command":"exit 0"}]}"#
3014                .to_string(),
3015            r#"{"missing":[1,2],"prose_only":[]}"#.to_string(),
3016        ]);
3017        let contract = derive_contract(
3018            |_r: ContractDraftRequest| {
3019                let next = script.lock().unwrap().remove(0);
3020                async move { Ok::<_, String>(next) }
3021            },
3022            "do the thing",
3023            "Top-level entries: src",
3024            2,
3025            &["constraint one".to_string(), "constraint two".to_string()],
3026        )
3027        .await
3028        .unwrap();
3029
3030        assert!(
3031            contract.checks.iter().any(|c| c.name == "first_gated"),
3032            "the better draft must survive: {contract:?}"
3033        );
3034        assert!(
3035            contract.description.contains("constraint two")
3036                && !contract.description.contains("constraint one"),
3037            "only the genuinely ungated constraint is disclosed: {}",
3038            contract.description
3039        );
3040    }
3041
3042    /// A judge that fails (transport, timeout, garbage) must not burn the
3043    /// caller's attempt budget: carry-through verification fails OPEN.
3044    #[tokio::test]
3045    async fn a_failing_constraint_judge_does_not_block_derivation() {
3046        use std::sync::Mutex;
3047        let script = Mutex::new(vec![
3048            r#"{"description":"tests pass","checks":[{"name":"tests","command":"exit 0"}]}"#
3049                .to_string(),
3050            "the judge returned prose, not JSON".to_string(),
3051        ]);
3052        let contract = derive_contract(
3053            |_r: ContractDraftRequest| {
3054                let next = script.lock().unwrap().remove(0);
3055                async move { Ok::<_, String>(next) }
3056            },
3057            "make the failing tests pass",
3058            "Top-level entries: src",
3059            3,
3060            &["some constraint".to_string()],
3061        )
3062        .await
3063        .expect("an unusable judge must not fail the derivation");
3064        assert_eq!(contract.checks.len(), 1);
3065    }
3066}
3067
3068#[cfg(test)]
3069mod differential_tests {
3070    use super::*;
3071    use crate::coder::session::EventSink;
3072    use crate::coder::shell_tool::WorktreeExecutor;
3073
3074    fn check(name: &str, command: &str) -> ContractCheck {
3075        ContractCheck {
3076            name: name.into(),
3077            command: command.into(),
3078            expect_exit_zero: true,
3079            output_contains: None,
3080            timeout_secs: 10,
3081            baseline: false,
3082            differential: None,
3083        }
3084    }
3085
3086    fn capture(name: &str, output: &str, passed: bool) -> CheckResult {
3087        CheckResult {
3088            name: name.into(),
3089            passed,
3090            exit_code: Some(if passed { 0 } else { 1 }),
3091            output_tail: output.into(),
3092            duration_ms: 1,
3093            timed_out: false,
3094            deadline_clamped: false,
3095        }
3096    }
3097
3098    fn captures(name: &str, output: &str) -> BaselineCaptures {
3099        let mut m = BaselineCaptures::new();
3100        m.insert(name.into(), capture(name, output, true));
3101        m
3102    }
3103
3104    fn diff(baseline: &str, expect: DifferentialExpect) -> DifferentialCheck {
3105        DifferentialCheck {
3106            baseline: baseline.into(),
3107            expect,
3108        }
3109    }
3110
3111    // ---- schema ---------------------------------------------------------
3112
3113    /// The pre-#1067 wire shape parses unchanged: both fields are additive.
3114    #[test]
3115    fn a_contract_without_the_new_fields_still_parses() {
3116        let c: ContractCheck = serde_json::from_str(
3117            r#"{"name": "tests", "command": "cargo test", "timeout_secs": 600}"#,
3118        )
3119        .unwrap();
3120        assert!(!c.baseline);
3121        assert!(c.differential.is_none());
3122        // And the default serialization does not grow the wire shape.
3123        let v = serde_json::to_value(&c).unwrap();
3124        assert!(v.get("baseline").is_none());
3125        assert!(v.get("differential").is_none());
3126    }
3127
3128    /// Each differential kind round-trips through its wire spelling.
3129    #[test]
3130    fn differential_kinds_round_trip_on_the_wire() {
3131        let json = r#"{
3132            "name": "rows_decreased",
3133            "command": "cat counter.txt",
3134            "differential": {
3135                "baseline": "orphan_rows",
3136                "expect": { "delta_within": { "max": -100.0 } }
3137            }
3138        }"#;
3139        let c: ContractCheck = serde_json::from_str(json).unwrap();
3140        // Exhaustive: a new kind must extend this match or the build fails.
3141        match &c.differential.as_ref().unwrap().expect {
3142            DifferentialExpect::DeltaWithin { min, max } => {
3143                assert_eq!(*min, None);
3144                assert_eq!(*max, Some(-100.0));
3145            }
3146            DifferentialExpect::Changed | DifferentialExpect::Unchanged => {
3147                panic!("parsed the wrong kind")
3148            }
3149        }
3150        for (wire, expect) in [
3151            ("\"changed\"", DifferentialExpect::Changed),
3152            ("\"unchanged\"", DifferentialExpect::Unchanged),
3153        ] {
3154            let parsed: DifferentialExpect = serde_json::from_str(wire).unwrap();
3155            assert_eq!(parsed, expect);
3156        }
3157    }
3158
3159    // ---- validation -------------------------------------------------------
3160
3161    fn contract(checks: Vec<ContractCheck>) -> OutcomeContract {
3162        OutcomeContract {
3163            description: "d".into(),
3164            checks,
3165        }
3166    }
3167
3168    #[test]
3169    fn a_baseline_capture_needs_no_assertion_but_a_normal_check_still_does() {
3170        let mut cap = check("before", "cat counter.txt");
3171        cap.baseline = true;
3172        cap.expect_exit_zero = false;
3173        let mut gate = check("after", "cat counter.txt");
3174        gate.differential = Some(diff("before", DifferentialExpect::Changed));
3175        assert!(contract(vec![cap, gate]).validate().is_empty());
3176
3177        let mut bare = check("nothing", "true");
3178        bare.expect_exit_zero = false;
3179        let issues = contract(vec![bare]).validate();
3180        assert!(issues.iter().any(|i| i.contains("asserts nothing")));
3181    }
3182
3183    #[test]
3184    fn validation_rejects_the_malformed_differential_shapes() {
3185        // A capture that also diffs.
3186        let mut both = check("x", "true");
3187        both.baseline = true;
3188        both.differential = Some(diff("x", DifferentialExpect::Changed));
3189        let issues = contract(vec![both, check("y", "true")]).validate();
3190        assert!(
3191            issues
3192                .iter()
3193                .any(|i| i.contains("both a baseline capture and a differential")),
3194            "{issues:?}"
3195        );
3196
3197        // A reference to a capture that does not exist.
3198        let mut orphan = check("after", "true");
3199        orphan.differential = Some(diff("nowhere", DifferentialExpect::Changed));
3200        let issues = contract(vec![orphan]).validate();
3201        assert!(
3202            issues
3203                .iter()
3204                .any(|i| i.contains("no check by that name is marked baseline")),
3205            "{issues:?}"
3206        );
3207
3208        // A reference to a capture declared after the differential.
3209        let mut early = check("after", "true");
3210        early.differential = Some(diff("before", DifferentialExpect::Changed));
3211        let mut late_cap = check("before", "true");
3212        late_cap.baseline = true;
3213        let issues = contract(vec![early, late_cap]).validate();
3214        assert!(
3215            issues.iter().any(|i| i.contains("declared after it")),
3216            "{issues:?}"
3217        );
3218
3219        // delta_within with no bounds asserts nothing.
3220        let mut cap = check("before", "true");
3221        cap.baseline = true;
3222        let mut unbounded = check("after", "true");
3223        unbounded.differential = Some(diff(
3224            "before",
3225            DifferentialExpect::DeltaWithin {
3226                min: None,
3227                max: None,
3228            },
3229        ));
3230        let issues = contract(vec![cap.clone(), unbounded]).validate();
3231        assert!(issues.iter().any(|i| i.contains("no bounds")), "{issues:?}");
3232
3233        // min above max is unsatisfiable.
3234        let mut inverted = check("after", "true");
3235        inverted.differential = Some(diff(
3236            "before",
3237            DifferentialExpect::DeltaWithin {
3238                min: Some(5.0),
3239                max: Some(1.0),
3240            },
3241        ));
3242        let issues = contract(vec![cap.clone(), inverted]).validate();
3243        assert!(
3244            issues.iter().any(|i| i.contains("min above max")),
3245            "{issues:?}"
3246        );
3247
3248        // A contract that only captures gates nothing.
3249        let issues = contract(vec![cap]).validate();
3250        assert!(
3251            issues
3252                .iter()
3253                .any(|i| i.contains("every check is a baseline capture")),
3254            "{issues:?}"
3255        );
3256    }
3257
3258    // ---- the three kinds, decided by the runtime --------------------------
3259
3260    #[test]
3261    fn changed_passes_on_a_move_and_fails_identical_with_the_message() {
3262        let d = diff("hb", DifferentialExpect::Changed);
3263        let caps = captures("hb", "ERROR");
3264        assert!(evaluate_differential(&d, &caps, "HEALTHY").is_ok());
3265        let err = evaluate_differential(&d, &caps, "ERROR").unwrap_err();
3266        assert!(
3267            err.contains("expected the output to CHANGE from baseline 'hb'"),
3268            "{err}"
3269        );
3270        assert!(err.contains("identical to the captured value"), "{err}");
3271    }
3272
3273    #[test]
3274    fn unchanged_holds_the_control_group_and_names_the_violation() {
3275        let d = diff("control", DifferentialExpect::Unchanged);
3276        let caps = captures("control", "rows=42");
3277        assert!(evaluate_differential(&d, &caps, "rows=42\n").is_ok());
3278        let err = evaluate_differential(&d, &caps, "rows=41").unwrap_err();
3279        assert!(err.contains("UNCHANGED from baseline 'control'"), "{err}");
3280        assert!(err.contains("control-group"), "{err}");
3281        assert!(
3282            err.contains("\"rows=42\"") && err.contains("\"rows=41\""),
3283            "{err}"
3284        );
3285    }
3286
3287    #[test]
3288    fn delta_within_bounds_both_sides_and_reports_the_numbers() {
3289        let caps = captures("orphans", "orphaned rows: 435,594");
3290        // "fell by at least 100": delta <= -100.
3291        let d = diff(
3292            "orphans",
3293            DifferentialExpect::DeltaWithin {
3294                min: None,
3295                max: Some(-100.0),
3296            },
3297        );
3298        assert!(evaluate_differential(&d, &caps, "orphaned rows: 76,330").is_ok());
3299        let err = evaluate_differential(&d, &caps, "orphaned rows: 435,600").unwrap_err();
3300        assert!(err.contains("delta 6"), "{err}");
3301        assert!(err.contains("435594 -> 435600"), "{err}");
3302        assert!(
3303            err.contains("outside the allowed bounds [-inf, -100]"),
3304            "{err}"
3305        );
3306
3307        // A lower bound alone works too ("grew by at least 5").
3308        let up = diff(
3309            "orphans",
3310            DifferentialExpect::DeltaWithin {
3311                min: Some(5.0),
3312                max: None,
3313            },
3314        );
3315        assert!(evaluate_differential(&up, &caps, "435600").is_ok());
3316        let err = evaluate_differential(&up, &caps, "435595").unwrap_err();
3317        assert!(
3318            err.contains("outside the allowed bounds [5, +inf]"),
3319            "{err}"
3320        );
3321    }
3322
3323    #[test]
3324    fn delta_within_names_which_side_was_not_numeric() {
3325        let d = diff(
3326            "n",
3327            DifferentialExpect::DeltaWithin {
3328                min: None,
3329                max: Some(0.0),
3330            },
3331        );
3332        let err = evaluate_differential(&d, &captures("n", "no digits here"), "7").unwrap_err();
3333        assert!(
3334            err.contains("baseline 'n' captured no numeric value"),
3335            "{err}"
3336        );
3337        let err = evaluate_differential(&d, &captures("n", "7"), "no digits here").unwrap_err();
3338        assert!(
3339            err.contains("the check output carries no numeric value"),
3340            "{err}"
3341        );
3342    }
3343
3344    #[test]
3345    fn a_missing_or_failed_capture_fails_closed_with_the_reason() {
3346        let d = diff("gone", DifferentialExpect::Changed);
3347        let err = evaluate_differential(&d, &BaselineCaptures::new(), "x").unwrap_err();
3348        assert!(err.contains("baseline 'gone' was never captured"), "{err}");
3349
3350        let mut caps = BaselineCaptures::new();
3351        caps.insert("gone".into(), capture("gone", "x", false));
3352        let err = evaluate_differential(&d, &caps, "y").unwrap_err();
3353        assert!(err.contains("failed at capture time"), "{err}");
3354    }
3355
3356    #[test]
3357    fn first_number_reads_counters_out_of_prose() {
3358        assert_eq!(first_number("orphaned rows: 435,594"), Some(435_594.0));
3359        assert_eq!(first_number("-12.5 degrees"), Some(-12.5));
3360        assert_eq!(first_number("count=76330"), Some(76_330.0));
3361        assert_eq!(first_number("no digits"), None);
3362        assert_eq!(first_number(""), None);
3363    }
3364
3365    // ---- end to end: the bead's probe --------------------------------------
3366
3367    /// A fixture "system" (a counter file), a baseline capture of it, and a
3368    /// differential final check asserting the value decreased. Before #1067 no
3369    /// baseline concept parsed; now the capture pass records the before-value,
3370    /// the work moves the counter, and the gate decides the differential — with
3371    /// BOTH executions present in the results the events narrate.
3372    #[tokio::test]
3373    async fn a_counter_decrease_is_expressible_and_enforced_end_to_end() {
3374        let dir = tempfile::tempdir().unwrap();
3375        std::fs::write(dir.path().join("counter.txt"), "435594\n").unwrap();
3376        let exec = WorktreeExecutor::new(dir.path());
3377        let sink = EventSink::test_sink();
3378
3379        let mut cap = check("orphan_rows", "cat counter.txt");
3380        cap.baseline = true;
3381        let mut gate = check("orphan_rows_decreased", "cat counter.txt");
3382        gate.differential = Some(diff(
3383            "orphan_rows",
3384            DifferentialExpect::DeltaWithin {
3385                min: None,
3386                max: Some(-100.0),
3387            },
3388        ));
3389        let contract = contract(vec![cap, gate]);
3390        assert!(contract.validate().is_empty());
3391
3392        // Session start: the baseline pass IS the capture execution — and the
3393        // differential is RED here (delta 0), so this contract never reads as
3394        // gating nothing.
3395        let baseline = evaluate_contract_baseline(&contract, &exec).await;
3396        assert!(baseline[0].passed, "the capture itself succeeds");
3397        assert!(
3398            !baseline[1].passed,
3399            "nothing has changed yet, so the differential must be red at baseline"
3400        );
3401        assert!(!baseline_gates_nothing(&baseline));
3402        let caps = collect_baseline_captures(&contract, &baseline);
3403        assert_eq!(caps.len(), 1);
3404        assert!(caps["orphan_rows"].output_tail.contains("435594"));
3405
3406        // The "work": the fixture system's counter falls.
3407        std::fs::write(dir.path().join("counter.txt"), "76330\n").unwrap();
3408
3409        // The gate: capture carried over (not re-run), differential decided.
3410        let results =
3411            evaluate_contract_within_baselines(&contract, &exec, &sink, None, &caps).await;
3412        assert_eq!(results.len(), 2, "both executions are present");
3413        assert!(
3414            results[0].output_tail.contains("435594"),
3415            "the capture result is the session-start one, not a re-run: {}",
3416            results[0].output_tail
3417        );
3418        assert!(results[1].passed, "435594 -> 76330 is a delta of -359264");
3419        assert!(results.iter().all(|r| r.passed));
3420
3421        // And had the counter RISEN instead, the same gate refuses.
3422        std::fs::write(dir.path().join("counter.txt"), "500000\n").unwrap();
3423        let results =
3424            evaluate_contract_within_baselines(&contract, &exec, &sink, None, &caps).await;
3425        assert!(!results[1].passed);
3426        assert!(
3427            results[1]
3428                .output_tail
3429                .contains("outside the allowed bounds"),
3430            "{}",
3431            results[1].output_tail
3432        );
3433    }
3434
3435    /// The control-group claim: a file the work must not touch, captured and
3436    /// asserted unchanged.
3437    #[tokio::test]
3438    async fn a_control_group_unchanged_claim_is_expressible_and_enforced() {
3439        let dir = tempfile::tempdir().unwrap();
3440        std::fs::write(dir.path().join("control.txt"), "tenant-b rows: 42\n").unwrap();
3441        let exec = WorktreeExecutor::new(dir.path());
3442        let sink = EventSink::test_sink();
3443
3444        let mut cap = check("control_before", "cat control.txt");
3445        cap.baseline = true;
3446        let mut gate = check("control_unmoved", "cat control.txt");
3447        gate.differential = Some(diff("control_before", DifferentialExpect::Unchanged));
3448        let contract = contract(vec![cap, gate]);
3449        assert!(contract.validate().is_empty());
3450
3451        let baseline = evaluate_contract_baseline(&contract, &exec).await;
3452        // The control-group claim is legitimately green at baseline — and the
3453        // capture pass compares against the value captured moments before.
3454        assert!(baseline.iter().all(|r| r.passed));
3455        let caps = collect_baseline_captures(&contract, &baseline);
3456
3457        // Untouched: green.
3458        let results =
3459            evaluate_contract_within_baselines(&contract, &exec, &sink, None, &caps).await;
3460        assert!(results.iter().all(|r| r.passed));
3461
3462        // Touched: the violation is named.
3463        std::fs::write(dir.path().join("control.txt"), "tenant-b rows: 41\n").unwrap();
3464        let results =
3465            evaluate_contract_within_baselines(&contract, &exec, &sink, None, &caps).await;
3466        assert!(!results[1].passed);
3467        assert!(
3468            results[1].output_tail.contains("control-group"),
3469            "{}",
3470            results[1].output_tail
3471        );
3472    }
3473
3474    /// The capture-less signatures fail CLOSED on a differential contract: a
3475    /// caller that never captured cannot have its differentials silently pass.
3476    #[tokio::test]
3477    async fn without_captures_a_differential_check_fails_closed() {
3478        let dir = tempfile::tempdir().unwrap();
3479        std::fs::write(dir.path().join("counter.txt"), "1\n").unwrap();
3480        let exec = WorktreeExecutor::new(dir.path());
3481        let sink = EventSink::test_sink();
3482
3483        let mut cap = check("before", "cat counter.txt");
3484        cap.baseline = true;
3485        let mut gate = check("after", "cat counter.txt");
3486        gate.differential = Some(diff("before", DifferentialExpect::Changed));
3487        let contract = contract(vec![cap, gate]);
3488
3489        let results = evaluate_contract(&contract, &exec, &sink).await;
3490        assert!(
3491            !results[0].passed && results[0].output_tail.contains("never captured"),
3492            "{}",
3493            results[0].output_tail
3494        );
3495        assert!(
3496            !results[1].passed && results[1].output_tail.contains("never captured"),
3497            "{}",
3498            results[1].output_tail
3499        );
3500    }
3501
3502    /// Render names the before/after structure so the confirmation the operator
3503    /// reads shows the claim, not just the command.
3504    #[test]
3505    fn render_states_captures_and_differentials() {
3506        let mut cap = check("orphan_rows", "cat counter.txt");
3507        cap.baseline = true;
3508        let mut gate = check("decreased", "cat counter.txt");
3509        gate.differential = Some(diff(
3510            "orphan_rows",
3511            DifferentialExpect::DeltaWithin {
3512                min: None,
3513                max: Some(-100.0),
3514            },
3515        ));
3516        let rendered = contract(vec![cap, gate]).render();
3517        assert!(
3518            rendered.contains("baseline capture at session start"),
3519            "{rendered}"
3520        );
3521        assert!(
3522            rendered.contains("vs baseline 'orphan_rows': delta within [-inf, -100]"),
3523            "{rendered}"
3524        );
3525    }
3526}