1use 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#[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 pub fn names() -> String {
84 Self::ALL
85 .iter()
86 .map(|k| k.as_str())
87 .collect::<Vec<_>>()
88 .join(", ")
89 }
90}
91
92#[derive(Debug, Clone, PartialEq)]
96pub struct ConfigChange {
97 pub key: OverrideKey,
98 pub value: String,
99}
100
101pub fn names_override_key(spec: &str) -> Option<OverrideKey> {
112 OverrideKey::parse(spec.split_once('=')?.0.trim())
113}
114
115pub 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 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
190pub const STATUS_STAGED: &str = "staged";
195pub const STATUS_ACCEPTED: &str = "accepted";
197pub const STATUS_REJECTED: &str = "rejected";
199pub const STATUS_REVERTED: &str = "reverted";
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct HarnessCandidate {
211 pub id: String,
212 pub created_at: String,
213 pub class: ChangeClass,
214 pub change: String,
216 pub metric: Metric,
218 pub rationale: String,
219 pub evidence: String,
221 #[serde(default)]
223 pub model: Option<String>,
224 pub status: String,
226 #[serde(default)]
227 pub measurement: Option<Measurement>,
228 #[serde(default)]
229 pub resolved_at: Option<String>,
230 #[serde(default)]
232 pub reason: Option<String>,
233}
234
235impl HarnessCandidate {
236 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#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct Measurement {
258 pub measured_at: String,
259 pub model: String,
260 pub disposition: String,
263 pub reason: String,
265 pub selection: TallyRecord,
266 pub holdout: TallyRecord,
267 pub work_baseline: u64,
268 pub work_candidate: u64,
269 pub episodes: Vec<String>,
271 #[serde(default)]
283 pub holdout_episodes: Vec<String>,
284 #[serde(default)]
285 pub seed: u64,
286 pub diverged: Vec<String>,
294 #[serde(default, skip_serializing_if = "Vec::is_empty")]
305 pub replay_caveats: Vec<String>,
306 pub skipped: usize,
309}
310
311pub struct Drawn {
316 pub episodes: Vec<String>,
318 pub holdout_episodes: Vec<String>,
319 pub seed: u64,
320 pub diverged: Vec<String>,
321 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
375pub struct AcceptedOverride {
376 pub key: String,
379 pub value: String,
380 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
391pub struct HarnessStore {
393 root: PathBuf,
394}
395
396impl HarnessStore {
397 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 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 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 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 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 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 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 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 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 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
551pub 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
569pub 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 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 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 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 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 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 assert_eq!(cfg.agent.compact_at_tokens, Some(24000));
692 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 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 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 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 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 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}