Skip to main content

mecha_core/
harness.rs

1//! Harness rumination: the candidate store behind `mecha harness`, and the
2//! override layer an accepted change rides in.
3//!
4//! This is the persistence half of the self-improvement loop. `diagnose.rs`
5//! proposes, `replay_run.rs` measures, `candidate.rs` judges — and until this
6//! module existed, the judgement evaporated when the process exited, which is
7//! why the loop had never run unattended: a nightly that proposes changes
8//! nobody reads is worse than no nightly. Here a proposal becomes a record,
9//! the record carries the measurement it was decided from, and an accepted
10//! change becomes one entry in an overrides file that any run can read and
11//! one command can revert.
12//!
13//! ## The override layer, and why the user always wins
14//!
15//! `overrides.toml` is applied to a [`Config`] **after defaults and before
16//! any file layer** ([`apply_accepted_overrides`], called from
17//! `Config::load` / `load_global`). Layering is assignment, so a key the
18//! user names in `config.toml` overwrites the accepted value — an override
19//! only ever fills space the user left empty. That is §13.3's "reversible"
20//! made structural: reverting is deleting a line, and nothing the loop does
21//! can pin a value against the user's own file.
22//!
23//! ## The closed set
24//!
25//! [`OverrideKey`] is the same closed set `mecha eval --ab-config` accepts,
26//! defined once here so the measurement arm and the acceptance arm cannot
27//! drift apart — a candidate measured under one applier and applied under
28//! another would be accepted on evidence about a different change. An open
29//! set would let a proposer reach settings whose effect replay cannot
30//! measure, or worse, security settings, which are never a measurement's to
31//! decide. An unknown key in the overrides file is **skipped loudly and
32//! never applied** — the file is machine-written, but a boundary that trusts
33//! its writer is not one.
34
35use crate::candidate::{ChangeClass, Judgement, Metric};
36use crate::config::Config;
37use crate::message::Effort;
38use anyhow::{Context, Result};
39use serde::{Deserialize, Serialize};
40use std::os::fd::AsRawFd;
41use std::path::{Path, PathBuf};
42
43// ─── The closed set ─────────────────────────────────────────────────────────
44
45/// The configuration knobs an automated proposer may move. Closed on
46/// purpose; see the module docs.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum OverrideKey {
49    CompactAtTokens,
50    MaxTurns,
51    MaxOutputTokens,
52    Effort,
53}
54
55impl OverrideKey {
56    pub const ALL: [OverrideKey; 4] = [
57        OverrideKey::CompactAtTokens,
58        OverrideKey::MaxTurns,
59        OverrideKey::MaxOutputTokens,
60        OverrideKey::Effort,
61    ];
62
63    pub fn parse(key: &str) -> Option<OverrideKey> {
64        match key {
65            "compact_at_tokens" => Some(OverrideKey::CompactAtTokens),
66            "max_turns" => Some(OverrideKey::MaxTurns),
67            "max_output_tokens" => Some(OverrideKey::MaxOutputTokens),
68            "effort" => Some(OverrideKey::Effort),
69            _ => None,
70        }
71    }
72
73    pub fn as_str(self) -> &'static str {
74        match self {
75            OverrideKey::CompactAtTokens => "compact_at_tokens",
76            OverrideKey::MaxTurns => "max_turns",
77            OverrideKey::MaxOutputTokens => "max_output_tokens",
78            OverrideKey::Effort => "effort",
79        }
80    }
81
82    /// The keys, for an error message that names the whole set.
83    pub fn names() -> String {
84        Self::ALL
85            .iter()
86            .map(|k| k.as_str())
87            .collect::<Vec<_>>()
88            .join(", ")
89    }
90}
91
92/// A `KEY=VALUE` change, parsed and value-validated. The value is kept as the
93/// canonical string it parsed from, so one shape serialises to the overrides
94/// file and re-parses on load through the same validation.
95#[derive(Debug, Clone, PartialEq)]
96pub struct ConfigChange {
97    pub key: OverrideKey,
98    pub value: String,
99}
100
101/// The key half of a `KEY=VALUE`, if it names something this loop can override.
102///
103/// Separate from [`parse_change`] because the two questions have different
104/// answers and only one of them is about the proposer. `max_turns=0` names a
105/// real knob with a value that was refused; `context.auto_compact=true` names
106/// nothing at all. The first is a config change a human can correct, the
107/// second is a request that someone add a setting — and until this existed
108/// both were stored `class: Config, status: staged`, which reads to a reviewer
109/// as a config change waiting to be applied when it is a feature request for a
110/// knob that has never existed.
111pub fn names_override_key(spec: &str) -> Option<OverrideKey> {
112    OverrideKey::parse(spec.split_once('=')?.0.trim())
113}
114
115/// Parse a proposal's `KEY=VALUE` into the closed set, validating the value.
116///
117/// Refusal is the common case and the safe one: a proposal whose change does
118/// not parse here is not a config change this loop can measure or apply, so
119/// it goes to a human instead.
120pub fn parse_change(spec: &str) -> Result<ConfigChange> {
121    let (key, value) = spec
122        .split_once('=')
123        .with_context(|| format!("expected KEY=VALUE, got `{spec}`"))?;
124    let key = key.trim();
125    let value = value.trim();
126    let key = OverrideKey::parse(key).with_context(|| {
127        format!(
128            "`{key}` is not in the closed override set ({})",
129            OverrideKey::names()
130        )
131    })?;
132    let canonical = match key {
133        OverrideKey::CompactAtTokens => {
134            let n: u64 = value
135                .parse()
136                .with_context(|| format!("compact_at_tokens takes a number, got `{value}`"))?;
137            anyhow::ensure!(
138                n >= 1000,
139                "compact_at_tokens below 1000 would compact on nearly every turn"
140            );
141            n.to_string()
142        }
143        OverrideKey::MaxTurns => {
144            let n: u32 = value
145                .parse()
146                .with_context(|| format!("max_turns takes a number, got `{value}`"))?;
147            anyhow::ensure!(n >= 1, "max_turns must be at least 1");
148            n.to_string()
149        }
150        OverrideKey::MaxOutputTokens => {
151            let n: u64 = value
152                .parse()
153                .with_context(|| format!("max_output_tokens takes a number, got `{value}`"))?;
154            anyhow::ensure!(n >= 1, "max_output_tokens must be at least 1");
155            n.to_string()
156        }
157        OverrideKey::Effort => value
158            .parse::<Effort>()
159            .map_err(|e| anyhow::anyhow!("{e}"))?
160            .as_str()
161            .to_string(),
162    };
163    Ok(ConfigChange {
164        key,
165        value: canonical,
166    })
167}
168
169impl ConfigChange {
170    /// Apply onto an [`crate::config::AgentConfig`]. The value was validated
171    /// at parse time; a value that no longer parses (a hand-edited file) is
172    /// an error the caller reports, never a silent skip-and-apply-half.
173    pub fn apply_to_agent(&self, agent: &mut crate::config::AgentConfig) -> Result<()> {
174        match self.key {
175            OverrideKey::CompactAtTokens => agent.compact_at_tokens = Some(self.value.parse()?),
176            OverrideKey::MaxTurns => agent.max_turns = self.value.parse()?,
177            OverrideKey::MaxOutputTokens => agent.max_output_tokens = Some(self.value.parse()?),
178            OverrideKey::Effort => {
179                agent.effort = Some(self.value.parse().map_err(|e| anyhow::anyhow!("{e}"))?)
180            }
181        }
182        Ok(())
183    }
184
185    pub fn spec(&self) -> String {
186        format!("{}={}", self.key.as_str(), self.value)
187    }
188}
189
190// ─── Candidates ─────────────────────────────────────────────────────────────
191
192/// Waiting on a human — unmeasurable by replay, or measured without a verdict
193/// strong enough to act alone.
194pub const STATUS_STAGED: &str = "staged";
195/// Cleared the full gate; the override is live.
196pub const STATUS_ACCEPTED: &str = "accepted";
197/// Measured worse, or a guardrail moved, or a human said no.
198pub const STATUS_REJECTED: &str = "rejected";
199/// Was accepted, then a human took the override back out.
200pub const STATUS_REVERTED: &str = "reverted";
201
202/// One proposed harness change, from diagnosis through disposal.
203///
204/// The record keeps everything the decision was made from — the evidence
205/// brief, the prediction, the paired tallies — because "is this loop actually
206/// helping" has to be answerable from the store rather than from impression.
207/// Statuses are strings, not an enum, on the wire-format rule: a status this
208/// version does not know must not make the record unreadable to `list`.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct HarnessCandidate {
211    pub id: String,
212    pub created_at: String,
213    pub class: ChangeClass,
214    /// The change as the diagnostician wrote it — `KEY=VALUE` for config.
215    pub change: String,
216    /// The metric it predicted this would reduce.
217    pub metric: Metric,
218    pub rationale: String,
219    /// The counters brief the diagnostician reasoned from. Machine-authored.
220    pub evidence: String,
221    /// Model whose corpus was diagnosed, and whose sessions were replayed.
222    #[serde(default)]
223    pub model: Option<String>,
224    /// `staged` | `accepted` | `rejected` | `reverted`.
225    pub status: String,
226    #[serde(default)]
227    pub measurement: Option<Measurement>,
228    #[serde(default)]
229    pub resolved_at: Option<String>,
230    /// Why it sits where it does, human-readable.
231    #[serde(default)]
232    pub reason: Option<String>,
233}
234
235impl HarnessCandidate {
236    /// Whether this candidate still needs a person to look at it.
237    pub fn pending(&self) -> bool {
238        self.status == STATUS_STAGED
239    }
240}
241
242#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
243pub struct TallyRecord {
244    pub wins: usize,
245    pub losses: usize,
246    pub ties: usize,
247}
248
249impl std::fmt::Display for TallyRecord {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        write!(f, "{}+ {}- {}=", self.wins, self.losses, self.ties)
252    }
253}
254
255/// What the counterfactual replay measured, kept whole on the candidate.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct Measurement {
258    pub measured_at: String,
259    pub model: String,
260    /// `accept` | `propose` | `reject` — the gate's own verdict, which is not
261    /// the candidate's status: a `propose` verdict stages for a human.
262    pub disposition: String,
263    /// The gate's reason, empty for accept.
264    pub reason: String,
265    pub selection: TallyRecord,
266    pub holdout: TallyRecord,
267    pub work_baseline: u64,
268    pub work_candidate: u64,
269    /// Session ids paired and judged, selection slice first.
270    pub episodes: Vec<String>,
271    /// Which of those were the holdout, and the seed the uniform draw used.
272    ///
273    /// Recorded rather than recomputed, because it can no longer *be*
274    /// recomputed. The split used to be `is_holdout(id, holdout_in)` — a pure
275    /// function of the episode id, so any later reader could reconstruct which
276    /// episodes confirmed a result. Drawing uniformly from a pool makes the
277    /// split depend on the corpus as it stood at measurement time, which is
278    /// gone the moment another session is written. Without these two fields
279    /// "which episodes was this confirmed on" stops being answerable, which is
280    /// the property the drawing was introduced to protect: a sample nobody can
281    /// redraw is a sample nobody can check.
282    #[serde(default)]
283    pub holdout_episodes: Vec<String>,
284    #[serde(default)]
285    pub seed: u64,
286    /// Sessions dropped because an arm left the recording — a divergent
287    /// replay's stats describe a truncated run, and scoring one would let a
288    /// behaviour-visible change be graded on the fraction it tracked.
289    /// **Bare ids, and they stay that way**: `episodes`/`holdout_episodes`'s
290    /// own contract (a sample nobody can redraw is one nobody can check)
291    /// holds here too, and anything resolving an entry back to a session
292    /// path must not have to parse annotation out of it first.
293    pub diverged: Vec<String>,
294    /// What the replay was compromising on, per episode that carried a
295    /// compromise — "id — attached N times; replayed under the first
296    /// config" — **whatever became of the episode**, skipped ones included
297    /// (the caveat is computed at prepare time, before any arm drives): a
298    /// dropped one's divergence may say more about the compromise than
299    /// about the change, and a cleanly paired one feeds the tally that
300    /// gates acceptance,
301    /// which is the more consequential place for the decider reading
302    /// `mecha harness show` to know the replay was compromising. Beside
303    /// `diverged` rather than folded into it, so the ids stay joinable.
304    #[serde(default, skip_serializing_if = "Vec::is_empty")]
305    pub replay_caveats: Vec<String>,
306    /// Sessions that could not be replayed at all (unreadable, no recorded
307    /// calls, tool surface moved). Never evidence for either arm.
308    pub skipped: usize,
309}
310
311/// Which episodes a measurement drew, where they went, and what it could not
312/// use. One value rather than five arguments: they are produced together by
313/// one draw and read together by one reader, and threading them separately is
314/// how the seed came to be `eprintln!`'d and never stored.
315pub struct Drawn {
316    /// Every episode paired and judged, selection slice first.
317    pub episodes: Vec<String>,
318    pub holdout_episodes: Vec<String>,
319    pub seed: u64,
320    pub diverged: Vec<String>,
321    /// See [`Measurement::replay_caveats`].
322    pub replay_caveats: Vec<String>,
323    pub skipped: usize,
324}
325
326impl Measurement {
327    pub fn record(
328        judgement: &Judgement,
329        model: &str,
330        measured_at: String,
331        drawn: Drawn,
332    ) -> Measurement {
333        let Drawn {
334            episodes,
335            holdout_episodes,
336            seed,
337            diverged,
338            replay_caveats,
339            skipped,
340        } = drawn;
341        use crate::candidate::Disposition;
342        let (disposition, reason) = match &judgement.disposition {
343            Disposition::Accept => ("accept", String::new()),
344            Disposition::Propose(r) => ("propose", r.clone()),
345            Disposition::Reject(r) => ("reject", r.clone()),
346        };
347        let tally = |t: &crate::candidate::Tally| TallyRecord {
348            wins: t.wins,
349            losses: t.losses,
350            ties: t.ties,
351        };
352        Measurement {
353            measured_at,
354            model: model.to_string(),
355            disposition: disposition.to_string(),
356            reason,
357            selection: tally(&judgement.selection),
358            holdout: tally(&judgement.holdout),
359            work_baseline: judgement.work_baseline,
360            work_candidate: judgement.work_candidate,
361            episodes,
362            holdout_episodes,
363            seed,
364            diverged,
365            replay_caveats,
366            skipped,
367        }
368    }
369}
370
371// ─── The store ──────────────────────────────────────────────────────────────
372
373/// One accepted override: the line in `overrides.toml`, with its provenance.
374#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
375pub struct AcceptedOverride {
376    /// Canonical [`OverrideKey`] string. Kept as a string on the wire-format
377    /// rule; [`OverrideKey::parse`] is re-applied on every load.
378    pub key: String,
379    pub value: String,
380    /// The candidate this acceptance came from.
381    pub candidate: String,
382    pub accepted_at: String,
383}
384
385#[derive(Debug, Default, Serialize, Deserialize)]
386struct OverridesFile {
387    #[serde(default, rename = "override")]
388    overrides: Vec<AcceptedOverride>,
389}
390
391/// `~/.mecha/learning/harness/` — candidates and the overrides file.
392pub struct HarnessStore {
393    root: PathBuf,
394}
395
396impl HarnessStore {
397    /// Beside the learning store's other artifacts, so `MECHA_LEARNING_DIR`
398    /// relocates the whole learning surface together.
399    pub fn default_root() -> Result<PathBuf> {
400        Ok(crate::learning::LearningStore::default_root()?.join("harness"))
401    }
402
403    pub fn open(root: impl Into<PathBuf>) -> Result<HarnessStore> {
404        let root = root.into();
405        crate::create_private_dir(&root.join("candidates"))
406            .with_context(|| format!("creating {}", root.display()))?;
407        Ok(HarnessStore { root })
408    }
409
410    pub fn open_default() -> Result<HarnessStore> {
411        Self::open(Self::default_root()?)
412    }
413
414    pub fn root(&self) -> &Path {
415        &self.root
416    }
417
418    /// Write (or rewrite) one candidate, atomically — a nightly pass and a
419    /// `list` in a terminal must never meet over a half-written file.
420    pub fn write(&self, c: &HarnessCandidate) -> Result<()> {
421        let path = self.root.join("candidates").join(format!("{}.json", c.id));
422        let tmp = path.with_extension("json.tmp");
423        std::fs::write(&tmp, serde_json::to_string_pretty(c)?)?;
424        std::fs::rename(&tmp, &path)?;
425        Ok(())
426    }
427
428    /// Every candidate, oldest first. Unreadable files are skipped with a
429    /// warning — one bad record must not hide the store.
430    pub fn all(&self) -> Result<Vec<HarnessCandidate>> {
431        let dir = self.root.join("candidates");
432        if !dir.is_dir() {
433            return Ok(Vec::new());
434        }
435        let mut out = Vec::new();
436        for entry in std::fs::read_dir(&dir)? {
437            let path = entry?.path();
438            if path.extension().and_then(|e| e.to_str()) != Some("json") {
439                continue;
440            }
441            match serde_json::from_str(&std::fs::read_to_string(&path)?) {
442                Ok(c) => out.push(c),
443                Err(e) => tracing::warn!("skipping unreadable candidate {}: {e}", path.display()),
444            }
445        }
446        out.sort_by(|a: &HarnessCandidate, b: &HarnessCandidate| a.id.cmp(&b.id));
447        Ok(out)
448    }
449
450    /// Find one candidate by id or unique prefix. Ambiguity is an error
451    /// rather than a guess, same as session lookup.
452    pub fn find(&self, id: &str) -> Result<HarnessCandidate> {
453        let all = self.all()?;
454        let matches: Vec<&HarnessCandidate> = all.iter().filter(|c| c.id.starts_with(id)).collect();
455        match matches.len() {
456            0 => anyhow::bail!("no candidate matching `{id}`"),
457            1 => Ok(matches[0].clone()),
458            n => anyhow::bail!(
459                "`{id}` matches {n} candidates: {}",
460                matches
461                    .iter()
462                    .map(|c| c.id.as_str())
463                    .collect::<Vec<_>>()
464                    .join(", ")
465            ),
466        }
467    }
468
469    pub fn overrides_path(&self) -> PathBuf {
470        self.root.join("overrides.toml")
471    }
472
473    /// The currently accepted overrides, in file order.
474    pub fn overrides(&self) -> Result<Vec<AcceptedOverride>> {
475        let path = self.overrides_path();
476        if !path.exists() {
477            return Ok(Vec::new());
478        }
479        let text = std::fs::read_to_string(&path)
480            .with_context(|| format!("reading {}", path.display()))?;
481        let file: OverridesFile =
482            toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
483        Ok(file.overrides)
484    }
485
486    /// Install an override, replacing any earlier one on the same key.
487    /// Returns what it replaced, so the acceptance can record the reversal.
488    pub fn set_override(&self, ov: AcceptedOverride) -> Result<Option<AcceptedOverride>> {
489        let _lock = self.lock()?;
490        let mut all = self.overrides()?;
491        let replaced = all
492            .iter()
493            .position(|o| o.key == ov.key)
494            .map(|i| all.remove(i));
495        all.push(ov);
496        self.write_overrides(&all)?;
497        Ok(replaced)
498    }
499
500    /// Remove the override on `key`, returning it if there was one. Removal
501    /// returns the key to whatever the user's own config layers say — the
502    /// candidate files keep the history.
503    pub fn remove_override(&self, key: &str) -> Result<Option<AcceptedOverride>> {
504        let _lock = self.lock()?;
505        let mut all = self.overrides()?;
506        let removed = all.iter().position(|o| o.key == key).map(|i| all.remove(i));
507        if removed.is_some() {
508            self.write_overrides(&all)?;
509        }
510        Ok(removed)
511    }
512
513    fn write_overrides(&self, all: &[AcceptedOverride]) -> Result<()> {
514        let path = self.overrides_path();
515        let file = OverridesFile {
516            overrides: all.to_vec(),
517        };
518        let tmp = path.with_extension("toml.tmp");
519        std::fs::write(&tmp, toml::to_string_pretty(&file)?)?;
520        std::fs::rename(&tmp, &path)?;
521        Ok(())
522    }
523
524    /// Advisory flock over override mutations — a nightly acceptance and a
525    /// human `revert` are both read-modify-write on one file.
526    fn lock(&self) -> Result<std::fs::File> {
527        let file = std::fs::OpenOptions::new()
528            .create(true)
529            .truncate(false)
530            .write(true)
531            .open(self.root.join(".lock"))?;
532        // SAFETY: flock on an fd we own, held open by the returned guard.
533        if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
534            return Err(std::io::Error::last_os_error()).context("locking the harness store");
535        }
536        Ok(file)
537    }
538
539    /// Mint a candidate id: sortable timestamp plus a sub-second tail, so
540    /// two candidates minted close together cannot collide.
541    pub fn mint_id() -> String {
542        let now = chrono::Utc::now();
543        format!(
544            "hc-{}-{:04x}",
545            now.format("%Y%m%dT%H%M%S"),
546            (now.timestamp_subsec_nanos() ^ std::process::id()) & 0xffff
547        )
548    }
549}
550
551// ─── The config layer ───────────────────────────────────────────────────────
552
553/// Apply the accepted overrides at the default location onto a
554/// just-defaulted [`Config`]. Called from `Config::load` / `load_global`
555/// before any file layer merges, so the user's own config always wins.
556///
557/// Best-effort by design: an unreadable or malformed overrides file warns
558/// and applies nothing, because a performance knob must never be the reason
559/// every run fails to start. Contrast the sandbox, where silent degradation
560/// removes a boundary — an override that fails to apply leaves the config
561/// exactly as the user wrote it.
562pub fn apply_accepted_overrides(cfg: &mut Config) {
563    let Ok(root) = HarnessStore::default_root() else {
564        return;
565    };
566    apply_overrides_file(cfg, &root.join("overrides.toml"));
567}
568
569/// The testable half: apply one overrides file onto a config.
570pub fn apply_overrides_file(cfg: &mut Config, path: &Path) {
571    if !path.exists() {
572        return;
573    }
574    let text = match std::fs::read_to_string(path) {
575        Ok(t) => t,
576        Err(e) => {
577            tracing::warn!("harness overrides unreadable ({}): {e}", path.display());
578            return;
579        }
580    };
581    let file: OverridesFile = match toml::from_str(&text) {
582        Ok(f) => f,
583        Err(e) => {
584            tracing::warn!(
585                "harness overrides malformed ({}): {e} — applying none",
586                path.display()
587            );
588            return;
589        }
590    };
591    for ov in file.overrides {
592        // Re-validated on every load: the closed set is enforced where the
593        // value is *used*, not only where it was written.
594        let change = match parse_change(&format!("{}={}", ov.key, ov.value)) {
595            Ok(c) => c,
596            Err(e) => {
597                tracing::warn!(
598                    "harness override `{}={}` skipped: {e:#} (from candidate {})",
599                    ov.key,
600                    ov.value,
601                    ov.candidate
602                );
603                continue;
604            }
605        };
606        if let Err(e) = change.apply_to_agent(&mut cfg.agent) {
607            tracing::warn!(
608                "harness override `{}` failed to apply: {e:#}",
609                change.spec()
610            );
611        }
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    fn temp_dir() -> std::path::PathBuf {
620        let dir = std::env::temp_dir()
621            .join("mecha-harness-test")
622            .join(uuid::Uuid::new_v4().to_string());
623        std::fs::create_dir_all(&dir).unwrap();
624        dir
625    }
626
627    #[test]
628    fn the_closed_set_refuses_everything_outside_it() {
629        // The keys eval's --ab-config accepts, and nothing else.
630        assert!(parse_change("compact_at_tokens=24000").is_ok());
631        assert!(parse_change("max_turns=30").is_ok());
632        assert!(parse_change("max_output_tokens=8000").is_ok());
633        assert!(parse_change("effort=low").is_ok());
634
635        // The settings a proposer must never reach.
636        for hostile in [
637            "sandbox=none",
638            "trifecta=allow",
639            "outbox.tools=",
640            "context_window=999999",
641            "temperature=2.0",
642        ] {
643            assert!(parse_change(hostile).is_err(), "{hostile} must be refused");
644        }
645        // And a shape that is not KEY=VALUE at all.
646        assert!(parse_change("just prose").is_err());
647    }
648
649    #[test]
650    fn values_are_validated_not_just_typed() {
651        assert!(parse_change("compact_at_tokens=1").is_err());
652        assert!(parse_change("max_turns=0").is_err());
653        assert!(parse_change("max_turns=notanumber").is_err());
654        assert!(parse_change("effort=extreme").is_err());
655        // Whitespace tolerated, value canonicalised.
656        let c = parse_change(" effort = LOW ").unwrap();
657        assert_eq!(c.value, "low");
658    }
659
660    #[test]
661    fn overrides_apply_beneath_the_user_and_unknown_keys_are_skipped() {
662        let dir = temp_dir();
663        let path = dir.join("overrides.toml");
664        std::fs::write(
665            &path,
666            r#"
667[[override]]
668key = "compact_at_tokens"
669value = "24000"
670candidate = "hc-test"
671accepted_at = "2026-08-22T00:00:00Z"
672
673[[override]]
674key = "sandbox"
675value = "none"
676candidate = "hc-evil"
677accepted_at = "2026-08-22T00:00:00Z"
678
679[[override]]
680key = "max_turns"
681value = "notanumber"
682candidate = "hc-corrupt"
683accepted_at = "2026-08-22T00:00:00Z"
684"#,
685        )
686        .unwrap();
687
688        let mut cfg = Config::default();
689        apply_overrides_file(&mut cfg, &path);
690        // The known, valid key applied.
691        assert_eq!(cfg.agent.compact_at_tokens, Some(24000));
692        // The unknown key was skipped, not smuggled anywhere.
693        // The corrupt value was skipped, and the default survived.
694        assert_eq!(cfg.agent.max_turns, 40);
695    }
696
697    #[test]
698    fn a_malformed_overrides_file_applies_nothing() {
699        let dir = temp_dir();
700        let path = dir.join("overrides.toml");
701        std::fs::write(&path, "this is not toml [[[").unwrap();
702        let mut cfg = Config::default();
703        let before = cfg.agent.max_turns;
704        apply_overrides_file(&mut cfg, &path);
705        assert_eq!(cfg.agent.max_turns, before);
706    }
707
708    #[test]
709    fn set_and_remove_override_round_trip_and_replacement_is_returned() {
710        let dir = temp_dir();
711        let store = HarnessStore::open(&dir).unwrap();
712
713        let first = AcceptedOverride {
714            key: "compact_at_tokens".into(),
715            value: "24000".into(),
716            candidate: "hc-a".into(),
717            accepted_at: "2026-08-22T00:00:00Z".into(),
718        };
719        assert!(store.set_override(first.clone()).unwrap().is_none());
720
721        // Same key again: replaced, and the replacement is reported so the
722        // acceptance can record it.
723        let second = AcceptedOverride {
724            key: "compact_at_tokens".into(),
725            value: "20000".into(),
726            candidate: "hc-b".into(),
727            ..first.clone()
728        };
729        let replaced = store.set_override(second).unwrap();
730        assert_eq!(replaced.unwrap().candidate, "hc-a");
731
732        let all = store.overrides().unwrap();
733        assert_eq!(all.len(), 1);
734        assert_eq!(all[0].value, "20000");
735
736        // And the file it wrote applies.
737        let mut cfg = Config::default();
738        apply_overrides_file(&mut cfg, &store.overrides_path());
739        assert_eq!(cfg.agent.compact_at_tokens, Some(20000));
740
741        // Revert: removed, returned, and the file no longer applies it.
742        let removed = store.remove_override("compact_at_tokens").unwrap();
743        assert_eq!(removed.unwrap().value, "20000");
744        let mut cfg = Config::default();
745        apply_overrides_file(&mut cfg, &store.overrides_path());
746        assert_eq!(cfg.agent.compact_at_tokens, None);
747        // Removing what is not there is a no-op, not an error.
748        assert!(store
749            .remove_override("compact_at_tokens")
750            .unwrap()
751            .is_none());
752    }
753
754    #[test]
755    fn candidates_round_trip_and_an_unknown_status_still_loads() {
756        let dir = temp_dir();
757        let store = HarnessStore::open(&dir).unwrap();
758        let c = HarnessCandidate {
759            id: "hc-20260822T000000-0001".into(),
760            created_at: "2026-08-22T00:00:00Z".into(),
761            class: ChangeClass::Config,
762            change: "compact_at_tokens=24000".into(),
763            metric: Metric::CutShort,
764            rationale: "runs are dying at the ceiling".into(),
765            evidence: "runs: 64".into(),
766            model: Some("qwen3.6-35b-a3b".into()),
767            status: STATUS_STAGED.into(),
768            measurement: None,
769            resolved_at: None,
770            reason: None,
771        };
772        store.write(&c).unwrap();
773        let read = store.find("hc-2026").unwrap();
774        assert_eq!(read.change, c.change);
775        assert!(read.pending());
776
777        // A status minted by a future version must not unread the record.
778        let mut future = c.clone();
779        future.id = "hc-20260823T000000-0002".into();
780        future.status = "escalated".into();
781        store.write(&future).unwrap();
782        assert_eq!(store.all().unwrap().len(), 2);
783    }
784}