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 parse_change(spec: &str) -> Result<ConfigChange> {
107 let (key, value) = spec
108 .split_once('=')
109 .with_context(|| format!("expected KEY=VALUE, got `{spec}`"))?;
110 let key = key.trim();
111 let value = value.trim();
112 let key = OverrideKey::parse(key).with_context(|| {
113 format!(
114 "`{key}` is not in the closed override set ({})",
115 OverrideKey::names()
116 )
117 })?;
118 let canonical = match key {
119 OverrideKey::CompactAtTokens => {
120 let n: u64 = value
121 .parse()
122 .with_context(|| format!("compact_at_tokens takes a number, got `{value}`"))?;
123 anyhow::ensure!(
124 n >= 1000,
125 "compact_at_tokens below 1000 would compact on nearly every turn"
126 );
127 n.to_string()
128 }
129 OverrideKey::MaxTurns => {
130 let n: u32 = value
131 .parse()
132 .with_context(|| format!("max_turns takes a number, got `{value}`"))?;
133 anyhow::ensure!(n >= 1, "max_turns must be at least 1");
134 n.to_string()
135 }
136 OverrideKey::MaxOutputTokens => {
137 let n: u64 = value
138 .parse()
139 .with_context(|| format!("max_output_tokens takes a number, got `{value}`"))?;
140 anyhow::ensure!(n >= 1, "max_output_tokens must be at least 1");
141 n.to_string()
142 }
143 OverrideKey::Effort => value
144 .parse::<Effort>()
145 .map_err(|e| anyhow::anyhow!("{e}"))?
146 .as_str()
147 .to_string(),
148 };
149 Ok(ConfigChange {
150 key,
151 value: canonical,
152 })
153}
154
155impl ConfigChange {
156 pub fn apply_to_agent(&self, agent: &mut crate::config::AgentConfig) -> Result<()> {
160 match self.key {
161 OverrideKey::CompactAtTokens => agent.compact_at_tokens = Some(self.value.parse()?),
162 OverrideKey::MaxTurns => agent.max_turns = self.value.parse()?,
163 OverrideKey::MaxOutputTokens => agent.max_output_tokens = Some(self.value.parse()?),
164 OverrideKey::Effort => {
165 agent.effort = Some(self.value.parse().map_err(|e| anyhow::anyhow!("{e}"))?)
166 }
167 }
168 Ok(())
169 }
170
171 pub fn spec(&self) -> String {
172 format!("{}={}", self.key.as_str(), self.value)
173 }
174}
175
176pub const STATUS_STAGED: &str = "staged";
181pub const STATUS_ACCEPTED: &str = "accepted";
183pub const STATUS_REJECTED: &str = "rejected";
185pub const STATUS_REVERTED: &str = "reverted";
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct HarnessCandidate {
197 pub id: String,
198 pub created_at: String,
199 pub class: ChangeClass,
200 pub change: String,
202 pub metric: Metric,
204 pub rationale: String,
205 pub evidence: String,
207 #[serde(default)]
209 pub model: Option<String>,
210 pub status: String,
212 #[serde(default)]
213 pub measurement: Option<Measurement>,
214 #[serde(default)]
215 pub resolved_at: Option<String>,
216 #[serde(default)]
218 pub reason: Option<String>,
219}
220
221impl HarnessCandidate {
222 pub fn pending(&self) -> bool {
224 self.status == STATUS_STAGED
225 }
226}
227
228#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
229pub struct TallyRecord {
230 pub wins: usize,
231 pub losses: usize,
232 pub ties: usize,
233}
234
235impl std::fmt::Display for TallyRecord {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 write!(f, "{}+ {}- {}=", self.wins, self.losses, self.ties)
238 }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct Measurement {
244 pub measured_at: String,
245 pub model: String,
246 pub disposition: String,
249 pub reason: String,
251 pub selection: TallyRecord,
252 pub holdout: TallyRecord,
253 pub work_baseline: u64,
254 pub work_candidate: u64,
255 pub episodes: Vec<String>,
257 pub diverged: Vec<String>,
261 pub skipped: usize,
264}
265
266impl Measurement {
267 pub fn record(
268 judgement: &Judgement,
269 model: &str,
270 measured_at: String,
271 episodes: Vec<String>,
272 diverged: Vec<String>,
273 skipped: usize,
274 ) -> Measurement {
275 use crate::candidate::Disposition;
276 let (disposition, reason) = match &judgement.disposition {
277 Disposition::Accept => ("accept", String::new()),
278 Disposition::Propose(r) => ("propose", r.clone()),
279 Disposition::Reject(r) => ("reject", r.clone()),
280 };
281 let tally = |t: &crate::candidate::Tally| TallyRecord {
282 wins: t.wins,
283 losses: t.losses,
284 ties: t.ties,
285 };
286 Measurement {
287 measured_at,
288 model: model.to_string(),
289 disposition: disposition.to_string(),
290 reason,
291 selection: tally(&judgement.selection),
292 holdout: tally(&judgement.holdout),
293 work_baseline: judgement.work_baseline,
294 work_candidate: judgement.work_candidate,
295 episodes,
296 diverged,
297 skipped,
298 }
299 }
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
306pub struct AcceptedOverride {
307 pub key: String,
310 pub value: String,
311 pub candidate: String,
313 pub accepted_at: String,
314}
315
316#[derive(Debug, Default, Serialize, Deserialize)]
317struct OverridesFile {
318 #[serde(default, rename = "override")]
319 overrides: Vec<AcceptedOverride>,
320}
321
322pub struct HarnessStore {
324 root: PathBuf,
325}
326
327impl HarnessStore {
328 pub fn default_root() -> Result<PathBuf> {
331 Ok(crate::learning::LearningStore::default_root()?.join("harness"))
332 }
333
334 pub fn open(root: impl Into<PathBuf>) -> Result<HarnessStore> {
335 let root = root.into();
336 crate::create_private_dir(&root.join("candidates"))
337 .with_context(|| format!("creating {}", root.display()))?;
338 Ok(HarnessStore { root })
339 }
340
341 pub fn open_default() -> Result<HarnessStore> {
342 Self::open(Self::default_root()?)
343 }
344
345 pub fn root(&self) -> &Path {
346 &self.root
347 }
348
349 pub fn write(&self, c: &HarnessCandidate) -> Result<()> {
352 let path = self.root.join("candidates").join(format!("{}.json", c.id));
353 let tmp = path.with_extension("json.tmp");
354 std::fs::write(&tmp, serde_json::to_string_pretty(c)?)?;
355 std::fs::rename(&tmp, &path)?;
356 Ok(())
357 }
358
359 pub fn all(&self) -> Result<Vec<HarnessCandidate>> {
362 let dir = self.root.join("candidates");
363 if !dir.is_dir() {
364 return Ok(Vec::new());
365 }
366 let mut out = Vec::new();
367 for entry in std::fs::read_dir(&dir)? {
368 let path = entry?.path();
369 if path.extension().and_then(|e| e.to_str()) != Some("json") {
370 continue;
371 }
372 match serde_json::from_str(&std::fs::read_to_string(&path)?) {
373 Ok(c) => out.push(c),
374 Err(e) => tracing::warn!("skipping unreadable candidate {}: {e}", path.display()),
375 }
376 }
377 out.sort_by(|a: &HarnessCandidate, b: &HarnessCandidate| a.id.cmp(&b.id));
378 Ok(out)
379 }
380
381 pub fn find(&self, id: &str) -> Result<HarnessCandidate> {
384 let all = self.all()?;
385 let matches: Vec<&HarnessCandidate> = all.iter().filter(|c| c.id.starts_with(id)).collect();
386 match matches.len() {
387 0 => anyhow::bail!("no candidate matching `{id}`"),
388 1 => Ok(matches[0].clone()),
389 n => anyhow::bail!(
390 "`{id}` matches {n} candidates: {}",
391 matches
392 .iter()
393 .map(|c| c.id.as_str())
394 .collect::<Vec<_>>()
395 .join(", ")
396 ),
397 }
398 }
399
400 pub fn overrides_path(&self) -> PathBuf {
401 self.root.join("overrides.toml")
402 }
403
404 pub fn overrides(&self) -> Result<Vec<AcceptedOverride>> {
406 let path = self.overrides_path();
407 if !path.exists() {
408 return Ok(Vec::new());
409 }
410 let text = std::fs::read_to_string(&path)
411 .with_context(|| format!("reading {}", path.display()))?;
412 let file: OverridesFile =
413 toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
414 Ok(file.overrides)
415 }
416
417 pub fn set_override(&self, ov: AcceptedOverride) -> Result<Option<AcceptedOverride>> {
420 let _lock = self.lock()?;
421 let mut all = self.overrides()?;
422 let replaced = all
423 .iter()
424 .position(|o| o.key == ov.key)
425 .map(|i| all.remove(i));
426 all.push(ov);
427 self.write_overrides(&all)?;
428 Ok(replaced)
429 }
430
431 pub fn remove_override(&self, key: &str) -> Result<Option<AcceptedOverride>> {
435 let _lock = self.lock()?;
436 let mut all = self.overrides()?;
437 let removed = all.iter().position(|o| o.key == key).map(|i| all.remove(i));
438 if removed.is_some() {
439 self.write_overrides(&all)?;
440 }
441 Ok(removed)
442 }
443
444 fn write_overrides(&self, all: &[AcceptedOverride]) -> Result<()> {
445 let path = self.overrides_path();
446 let file = OverridesFile {
447 overrides: all.to_vec(),
448 };
449 let tmp = path.with_extension("toml.tmp");
450 std::fs::write(&tmp, toml::to_string_pretty(&file)?)?;
451 std::fs::rename(&tmp, &path)?;
452 Ok(())
453 }
454
455 fn lock(&self) -> Result<std::fs::File> {
458 let file = std::fs::OpenOptions::new()
459 .create(true)
460 .truncate(false)
461 .write(true)
462 .open(self.root.join(".lock"))?;
463 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
465 return Err(std::io::Error::last_os_error()).context("locking the harness store");
466 }
467 Ok(file)
468 }
469
470 pub fn mint_id() -> String {
473 let now = chrono::Utc::now();
474 format!(
475 "hc-{}-{:04x}",
476 now.format("%Y%m%dT%H%M%S"),
477 (now.timestamp_subsec_nanos() ^ std::process::id()) & 0xffff
478 )
479 }
480}
481
482pub fn apply_accepted_overrides(cfg: &mut Config) {
494 let Ok(root) = HarnessStore::default_root() else {
495 return;
496 };
497 apply_overrides_file(cfg, &root.join("overrides.toml"));
498}
499
500pub fn apply_overrides_file(cfg: &mut Config, path: &Path) {
502 if !path.exists() {
503 return;
504 }
505 let text = match std::fs::read_to_string(path) {
506 Ok(t) => t,
507 Err(e) => {
508 tracing::warn!("harness overrides unreadable ({}): {e}", path.display());
509 return;
510 }
511 };
512 let file: OverridesFile = match toml::from_str(&text) {
513 Ok(f) => f,
514 Err(e) => {
515 tracing::warn!(
516 "harness overrides malformed ({}): {e} — applying none",
517 path.display()
518 );
519 return;
520 }
521 };
522 for ov in file.overrides {
523 let change = match parse_change(&format!("{}={}", ov.key, ov.value)) {
526 Ok(c) => c,
527 Err(e) => {
528 tracing::warn!(
529 "harness override `{}={}` skipped: {e:#} (from candidate {})",
530 ov.key,
531 ov.value,
532 ov.candidate
533 );
534 continue;
535 }
536 };
537 if let Err(e) = change.apply_to_agent(&mut cfg.agent) {
538 tracing::warn!(
539 "harness override `{}` failed to apply: {e:#}",
540 change.spec()
541 );
542 }
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 fn temp_dir() -> std::path::PathBuf {
551 let dir = std::env::temp_dir()
552 .join("mecha-harness-test")
553 .join(uuid::Uuid::new_v4().to_string());
554 std::fs::create_dir_all(&dir).unwrap();
555 dir
556 }
557
558 #[test]
559 fn the_closed_set_refuses_everything_outside_it() {
560 assert!(parse_change("compact_at_tokens=24000").is_ok());
562 assert!(parse_change("max_turns=30").is_ok());
563 assert!(parse_change("max_output_tokens=8000").is_ok());
564 assert!(parse_change("effort=low").is_ok());
565
566 for hostile in [
568 "sandbox=none",
569 "trifecta=allow",
570 "outbox.tools=",
571 "context_window=999999",
572 "temperature=2.0",
573 ] {
574 assert!(parse_change(hostile).is_err(), "{hostile} must be refused");
575 }
576 assert!(parse_change("just prose").is_err());
578 }
579
580 #[test]
581 fn values_are_validated_not_just_typed() {
582 assert!(parse_change("compact_at_tokens=1").is_err());
583 assert!(parse_change("max_turns=0").is_err());
584 assert!(parse_change("max_turns=notanumber").is_err());
585 assert!(parse_change("effort=extreme").is_err());
586 let c = parse_change(" effort = LOW ").unwrap();
588 assert_eq!(c.value, "low");
589 }
590
591 #[test]
592 fn overrides_apply_beneath_the_user_and_unknown_keys_are_skipped() {
593 let dir = temp_dir();
594 let path = dir.join("overrides.toml");
595 std::fs::write(
596 &path,
597 r#"
598[[override]]
599key = "compact_at_tokens"
600value = "24000"
601candidate = "hc-test"
602accepted_at = "2026-08-22T00:00:00Z"
603
604[[override]]
605key = "sandbox"
606value = "none"
607candidate = "hc-evil"
608accepted_at = "2026-08-22T00:00:00Z"
609
610[[override]]
611key = "max_turns"
612value = "notanumber"
613candidate = "hc-corrupt"
614accepted_at = "2026-08-22T00:00:00Z"
615"#,
616 )
617 .unwrap();
618
619 let mut cfg = Config::default();
620 apply_overrides_file(&mut cfg, &path);
621 assert_eq!(cfg.agent.compact_at_tokens, Some(24000));
623 assert_eq!(cfg.agent.max_turns, 40);
626 }
627
628 #[test]
629 fn a_malformed_overrides_file_applies_nothing() {
630 let dir = temp_dir();
631 let path = dir.join("overrides.toml");
632 std::fs::write(&path, "this is not toml [[[").unwrap();
633 let mut cfg = Config::default();
634 let before = cfg.agent.max_turns;
635 apply_overrides_file(&mut cfg, &path);
636 assert_eq!(cfg.agent.max_turns, before);
637 }
638
639 #[test]
640 fn set_and_remove_override_round_trip_and_replacement_is_returned() {
641 let dir = temp_dir();
642 let store = HarnessStore::open(&dir).unwrap();
643
644 let first = AcceptedOverride {
645 key: "compact_at_tokens".into(),
646 value: "24000".into(),
647 candidate: "hc-a".into(),
648 accepted_at: "2026-08-22T00:00:00Z".into(),
649 };
650 assert!(store.set_override(first.clone()).unwrap().is_none());
651
652 let second = AcceptedOverride {
655 key: "compact_at_tokens".into(),
656 value: "20000".into(),
657 candidate: "hc-b".into(),
658 ..first.clone()
659 };
660 let replaced = store.set_override(second).unwrap();
661 assert_eq!(replaced.unwrap().candidate, "hc-a");
662
663 let all = store.overrides().unwrap();
664 assert_eq!(all.len(), 1);
665 assert_eq!(all[0].value, "20000");
666
667 let mut cfg = Config::default();
669 apply_overrides_file(&mut cfg, &store.overrides_path());
670 assert_eq!(cfg.agent.compact_at_tokens, Some(20000));
671
672 let removed = store.remove_override("compact_at_tokens").unwrap();
674 assert_eq!(removed.unwrap().value, "20000");
675 let mut cfg = Config::default();
676 apply_overrides_file(&mut cfg, &store.overrides_path());
677 assert_eq!(cfg.agent.compact_at_tokens, None);
678 assert!(store
680 .remove_override("compact_at_tokens")
681 .unwrap()
682 .is_none());
683 }
684
685 #[test]
686 fn candidates_round_trip_and_an_unknown_status_still_loads() {
687 let dir = temp_dir();
688 let store = HarnessStore::open(&dir).unwrap();
689 let c = HarnessCandidate {
690 id: "hc-20260822T000000-0001".into(),
691 created_at: "2026-08-22T00:00:00Z".into(),
692 class: ChangeClass::Config,
693 change: "compact_at_tokens=24000".into(),
694 metric: Metric::CutShort,
695 rationale: "runs are dying at the ceiling".into(),
696 evidence: "runs: 64".into(),
697 model: Some("qwen3.6-35b-a3b".into()),
698 status: STATUS_STAGED.into(),
699 measurement: None,
700 resolved_at: None,
701 reason: None,
702 };
703 store.write(&c).unwrap();
704 let read = store.find("hc-2026").unwrap();
705 assert_eq!(read.change, c.change);
706 assert!(read.pending());
707
708 let mut future = c.clone();
710 future.id = "hc-20260823T000000-0002".into();
711 future.status = "escalated".into();
712 store.write(&future).unwrap();
713 assert_eq!(store.all().unwrap().len(), 2);
714 }
715}