1use std::collections::{BTreeMap, BTreeSet};
35use std::io::{Read, Write};
36use std::path::{Path, PathBuf};
37use std::process::{Child, Command, ExitStatus, Stdio};
38use std::sync::{mpsc, Arc, Mutex};
39use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
40
41use sha2::{Digest, Sha256};
42
43use super::model::*;
44use super::{inputs_digest, usage, Declarations, DIR, RUN_SCHEMA};
45use crate::metadata::yaml;
46use crate::policy::{Policy, ProjectionMode};
47
48const VERIFY_LIMIT: Duration = Duration::from_secs(600);
51
52const NEVER_OVERWRITTEN: &str = "a recorded run is evidence and is never overwritten: exclude \
54 it in the methodology with a reason and record another repetition";
55
56pub fn run_id(task: &str, variant: &str, repetition: u32) -> String {
67 format!("{task}--{variant}--r{repetition}")
68}
69
70#[derive(Debug, Clone)]
103pub struct RunOptions {
104 pub root: PathBuf,
106 pub suite: String,
108 pub tasks: Vec<String>,
110 pub repetitions: Vec<u32>,
112 pub work_dir: PathBuf,
115 pub harness: PathBuf,
117 pub majordomus: PathBuf,
119 pub parallel: usize,
121 pub force: bool,
125 pub dry_run: bool,
127 pub session_timeout: Duration,
129}
130
131#[derive(Debug, Clone)]
133struct Job {
134 task: EconomicsTask,
135 variant: String,
136 repetition: u32,
137}
138
139fn sha256_file_tree(repo: &Path, root: &Path, extra: &[&Path]) -> String {
142 let mut files = Vec::new();
143 walk(root, root, &mut files);
144 crate::order::canonical(&mut files);
145 let mut h = Sha256::new();
146 for rel in &files {
147 h.update(rel.as_bytes());
148 h.update([0]);
149 h.update(std::fs::read(root.join(rel)).unwrap_or_default());
150 h.update([0]);
151 }
152 for p in extra {
153 h.update(
154 p.strip_prefix(repo)
155 .unwrap_or(p)
156 .to_string_lossy()
157 .as_bytes(),
158 );
159 h.update([0]);
160 h.update(std::fs::read(p).unwrap_or_default());
161 h.update([0]);
162 }
163 format!("{:x}", h.finalize())
164}
165
166fn walk(base: &Path, dir: &Path, out: &mut Vec<String>) {
167 let Ok(entries) = std::fs::read_dir(dir) else {
168 return;
169 };
170 for e in entries.flatten() {
171 let p = e.path();
172 let name = e.file_name().to_string_lossy().to_string();
173 if name == "__pycache__" || name == ".git" || name.ends_with(".pyc") {
174 continue;
175 }
176 if p.is_dir() {
177 walk(base, &p, out);
178 } else if let Ok(rel) = p.strip_prefix(base) {
179 out.push(rel.to_string_lossy().replace('\\', "/"));
180 }
181 }
182}
183
184fn copy_fixture(from: &Path, to: &Path) -> Result<(), String> {
185 let mut files = Vec::new();
186 walk(from, from, &mut files);
187 for rel in files {
188 let dst = to.join(&rel);
189 if let Some(parent) = dst.parent() {
190 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
191 }
192 std::fs::copy(from.join(&rel), &dst).map_err(|e| format!("{rel}: {e}"))?;
193 }
194 Ok(())
195}
196
197const TIED: &[&str] = &[
205 "CLAUDECODE",
206 "CLAUDE_CODE_ENTRYPOINT",
207 "CLAUDE_CODE_SESSION_ID",
208 "CLAUDE_CODE_SSE_PORT",
209 "CLAUDE_PROJECT_DIR",
210 "CARGO_TARGET_DIR",
211 "CARGO_BUILD_TARGET_DIR",
212 "GIT_DIR",
213 "GIT_WORK_TREE",
214 "GIT_INDEX_FILE",
215 "GIT_COMMON_DIR",
216 "GIT_OBJECT_DIRECTORY",
217 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
218 "GIT_PREFIX",
219];
220
221const TIED_PREFIXES: &[&str] = &["MAJORDOMUS_", "MJ_"];
224
225fn tied(name: &str) -> bool {
226 TIED.contains(&name) || TIED_PREFIXES.iter().any(|p| name.starts_with(p))
227}
228
229fn clean_env(cmd: &mut Command, bin: Option<&Path>) {
234 for (k, _) in std::env::vars_os() {
235 if k.to_str().is_some_and(tied) {
236 cmd.env_remove(&k);
237 }
238 }
239 if let Some(dir) = bin
240 .and_then(Path::parent)
241 .filter(|d| !d.as_os_str().is_empty())
242 {
243 let path = std::env::var("PATH").unwrap_or_default();
244 cmd.env("PATH", format!("{}:{path}", dir.display()));
245 }
246 cmd.env("PYTHONDONTWRITEBYTECODE", "1");
247 cmd.env("DISABLE_AUTOUPDATER", "1");
250}
251
252fn run_quiet(
253 dir: &Path,
254 program: &Path,
255 args: &[&str],
256 bin: Option<&Path>,
257 share: Option<&Path>,
258) -> Result<String, String> {
259 let mut cmd = Command::new(program);
260 cmd.args(args).current_dir(dir).stdin(Stdio::null());
261 clean_env(&mut cmd, bin);
262 if let Some(s) = share {
263 cmd.env("MAJORDOMUS_SHARE", s);
264 }
265 let out = cmd
266 .output()
267 .map_err(|e| format!("{}: {e}", program.display()))?;
268 if !out.status.success() {
269 return Err(format!(
270 "{} {} exited {}: {}",
271 program.display(),
272 args.join(" "),
273 out.status.code().unwrap_or(-1),
274 String::from_utf8_lossy(&out.stderr)
275 .lines()
276 .last()
277 .unwrap_or("")
278 ));
279 }
280 Ok(String::from_utf8_lossy(&out.stdout).to_string())
281}
282
283fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
284 run_quiet(dir, Path::new("git"), args, None, None)
285}
286
287#[cfg(unix)]
295mod groups {
296 use std::sync::atomic::{AtomicI32, Ordering};
297 use std::sync::Once;
298
299 const SLOTS: usize = 64;
300 static LIVE: [AtomicI32; SLOTS] = [const { AtomicI32::new(0) }; SLOTS];
301 static INSTALL: Once = Once::new();
302
303 pub struct Registered(Option<usize>);
305
306 pub fn register(pgid: u32) -> Registered {
309 INSTALL.call_once(|| {
310 let handler = on_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
311 for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP] {
312 unsafe { libc::signal(signal, handler) };
314 }
315 });
316 let Ok(pgid) = i32::try_from(pgid) else {
317 return Registered(None);
318 };
319 for (i, slot) in LIVE.iter().enumerate() {
320 if slot
321 .compare_exchange(0, pgid, Ordering::SeqCst, Ordering::SeqCst)
322 .is_ok()
323 {
324 return Registered(Some(i));
325 }
326 }
327 Registered(None)
328 }
329
330 impl Drop for Registered {
331 fn drop(&mut self) {
332 if let Some(i) = self.0 {
333 LIVE[i].store(0, Ordering::SeqCst);
334 }
335 }
336 }
337
338 extern "C" fn on_signal(signal: libc::c_int) {
339 for slot in &LIVE {
340 let pgid = slot.load(Ordering::SeqCst);
341 if pgid > 0 {
342 unsafe { libc::killpg(pgid, libc::SIGKILL) };
344 }
345 }
346 unsafe {
348 libc::signal(signal, libc::SIG_DFL);
349 libc::raise(signal);
350 }
351 }
352}
353
354#[cfg(not(unix))]
355mod groups {
356 pub struct Registered;
357
358 pub fn register(_: u32) -> Registered {
359 Registered
360 }
361}
362
363fn spawn_group(cmd: &mut Command) -> std::io::Result<(Child, groups::Registered)> {
365 #[cfg(unix)]
366 {
367 use std::os::unix::process::CommandExt;
368 cmd.process_group(0);
369 }
370 let child = cmd.spawn()?;
371 let registered = groups::register(child.id());
372 Ok((child, registered))
373}
374
375fn wait_bounded(child: &mut Child, limit: Duration) -> Result<Option<ExitStatus>, String> {
379 let started = Instant::now();
380 loop {
381 if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
382 return Ok(Some(status));
383 }
384 if started.elapsed() >= limit {
385 #[cfg(unix)]
386 unsafe {
389 libc::killpg(child.id() as libc::pid_t, libc::SIGKILL);
390 }
391 let _ = child.kill();
392 let _ = child.wait();
393 return Ok(None);
394 }
395 std::thread::sleep(Duration::from_millis(100));
396 }
397}
398
399struct Workspace {
405 dir: PathBuf,
406}
407
408impl Workspace {
409 fn create(run: &str) -> Result<Self, String> {
410 let nanos = SystemTime::now()
411 .duration_since(UNIX_EPOCH)
412 .map(|d| d.as_nanos())
413 .unwrap_or(0);
414 let digest = crate::policy::sha256_hex(&format!("{run}\n{}\n{nanos}", std::process::id()));
415 let dir = std::env::temp_dir().join(format!("mj-bench-{}", &digest[..12]));
416 std::fs::create_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
418 Ok(Self { dir })
419 }
420
421 fn repo(&self) -> PathBuf {
422 self.dir.join("repo")
423 }
424
425 fn mcp_config(&self) -> PathBuf {
426 self.dir.join("mcp.json")
427 }
428
429 fn remove(self) -> Result<(), String> {
431 std::fs::remove_dir_all(&self.dir).map_err(|e| format!("{}: {e}", self.dir.display()))
432 }
433}
434
435impl Drop for Workspace {
436 fn drop(&mut self) {
437 let _ = std::fs::remove_dir_all(&self.dir);
438 }
439}
440
441fn benchmark_policy(text: &str, region: &[&str]) -> Result<String, String> {
456 let mut text = text.to_string();
457 for target in region {
458 let line = format!(" target: {target}\n");
459 if !text.contains(&line) {
460 return Err(format!(
461 "the policy declares no projection with target {target}"
462 ));
463 }
464 text = text.replace(&line, &format!("{line} mode: region\n"));
465 }
466 let setting = "ensure_server_on_start: false";
467 let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
468 let opens_session = |l: &String| {
469 l.strip_prefix("session:").is_some_and(|rest| {
470 let rest = rest.trim();
471 rest.is_empty() || rest.starts_with('#')
472 })
473 };
474 if lines
475 .iter()
476 .any(|l| l.starts_with("session:") && !opens_session(l))
477 {
478 return Err("the policy's session block is not a block mapping".into());
479 }
480 match lines.iter().position(opens_session) {
481 None => {
482 lines.push("session:".into());
483 lines.push(format!(" {setting}"));
484 }
485 Some(start) => {
486 let body = start + 1;
488 let end = lines[body..]
489 .iter()
490 .position(|l| {
491 !l.is_empty() && !l.starts_with(char::is_whitespace) && !l.starts_with('#')
492 })
493 .map_or(lines.len(), |i| body + i);
494 let indent_of = |l: &String| l.len() - l.trim_start().len();
495 let indent = lines[body..end]
496 .iter()
497 .find(|l| {
498 let t = l.trim_start();
499 !t.is_empty() && !t.starts_with('#')
500 })
501 .map_or(2, indent_of);
502 let line = format!("{}{setting}", " ".repeat(indent));
503 let key = lines[body..end].iter().position(|l| {
504 indent_of(l) == indent && l.trim_start().starts_with("ensure_server_on_start:")
505 });
506 match key {
507 Some(i) => lines[body + i] = line,
508 None => lines.insert(body, line),
509 }
510 }
511 }
512 let mut out = lines.join("\n");
513 out.push('\n');
514 let policy: Policy = yaml::parse_into(&out)
515 .map_err(|e| format!("the benchmark's policy does not read back: {e}"))?;
516 if policy.session.ensure_server_on_start {
517 return Err("session.ensure_server_on_start did not read back as false".into());
518 }
519 for target in region {
520 let regional = policy
521 .projections
522 .iter()
523 .any(|p| p.target == *target && p.mode == ProjectionMode::Region);
524 if !regional {
525 return Err(format!(
526 "the projection of {target} did not read back in region mode"
527 ));
528 }
529 }
530 Ok(out)
531}
532
533const WORKSPACE_AUTHOR: (&str, &str) = ("Developer", "developer@example.invalid");
537const WORKSPACE_FIRST_COMMIT: &str = "Initial commit";
540
541fn prepare(opts: &RunOptions, job: &Job, treatment: bool, ws: &Path) -> Result<String, String> {
544 if ws.exists() {
545 std::fs::remove_dir_all(ws).map_err(|e| format!("{}: {e}", ws.display()))?;
546 }
547 std::fs::create_dir_all(ws).map_err(|e| format!("{}: {e}", ws.display()))?;
548 copy_fixture(&opts.root.join(&job.task.fixture), ws)?;
549 git(ws, &["init", "-q", "-b", "main"])?;
550 git(ws, &["config", "user.name", WORKSPACE_AUTHOR.0])?;
551 git(ws, &["config", "user.email", WORKSPACE_AUTHOR.1])?;
552 git(ws, &["config", "commit.gpgsign", "false"])?;
553 if let Some(setup) = &job.task.setup {
554 git(ws, &["apply", &opts.root.join(setup).to_string_lossy()])?;
555 }
556 git(ws, &["add", "-A"])?;
557 git(ws, &["commit", "-qm", WORKSPACE_FIRST_COMMIT])?;
558 if treatment {
559 let share = opts.root.join("share");
560 let bin = Some(opts.majordomus.as_path());
561 run_quiet(ws, &opts.majordomus, &["init"], bin, Some(&share))?;
562 let policy = ws.join(".ai/repo/policy.yaml");
563 let read =
564 |p: &Path| std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()));
565 let kept: Vec<&str> = ["CLAUDE.md", "AGENTS.md", "GEMINI.md"]
566 .into_iter()
567 .filter(|t| ws.join(t).exists())
568 .collect();
569 let text = benchmark_policy(&read(&policy)?, &kept)?;
570 std::fs::write(&policy, text).map_err(|e| format!("{}: {e}", policy.display()))?;
571 for args in [
572 &["update"][..],
573 &["capture", "install", "--provider", "claude-code"][..],
574 ] {
575 run_quiet(ws, &opts.majordomus, args, bin, Some(&share))?;
576 }
577 let installed: Policy = yaml::parse_into(&read(&policy)?)?;
579 if installed.session.ensure_server_on_start {
580 return Err("the installation turned session.ensure_server_on_start back on".into());
581 }
582 git(ws, &["add", "-A"])?;
583 git(ws, &["commit", "-qm", "Install Majordomus"])?;
584 }
585 Ok(git(ws, &["rev-parse", "HEAD"])?.trim().to_string())
586}
587
588fn harness_args(model: &str, budget: Option<u32>, empty_mcp: &Path) -> Vec<String> {
590 let mut a: Vec<String> = [
591 "--output-format",
592 "stream-json",
593 "--verbose",
594 "--model",
595 model,
596 "--setting-sources",
597 "project,local",
598 "--strict-mcp-config",
599 "--mcp-config",
600 ]
601 .iter()
602 .map(|s| s.to_string())
603 .collect();
604 a.push(empty_mcp.to_string_lossy().to_string());
605 for s in [
606 "--disable-slash-commands",
607 "--no-session-persistence",
608 "--permission-mode",
609 "bypassPermissions",
610 ] {
611 a.push(s.into());
612 }
613 if let Some(b) = budget {
614 a.push("--max-budget-usd".into());
615 a.push(b.to_string());
616 }
617 a
618}
619
620fn configuration_digest(args: &[String]) -> String {
625 let masked: Vec<&str> = args
626 .iter()
627 .map(|a| {
628 if a.ends_with("mcp.json") {
629 "<empty-mcp>"
630 } else {
631 a.as_str()
632 }
633 })
634 .collect();
635 crate::policy::sha256_hex(&masked.join(" "))
636}
637
638fn harness_version(harness: &Path) -> String {
639 let mut cmd = Command::new(harness);
640 cmd.arg("--version")
641 .stdin(Stdio::null())
642 .stderr(Stdio::null());
643 clean_env(&mut cmd, None);
644 cmd.output()
645 .ok()
646 .map(|o| {
647 String::from_utf8_lossy(&o.stdout)
648 .split_whitespace()
649 .next()
650 .unwrap_or("unknown")
651 .to_string()
652 })
653 .unwrap_or_else(|| "unknown".into())
654}
655
656fn session(
659 opts: &RunOptions,
660 ws: &Path,
661 transcript: &Path,
662 args: &[String],
663 prompt: &str,
664 index: u32,
665 bin: Option<&Path>,
666) -> Result<EconomicsSession, String> {
667 let file =
668 std::fs::File::create(transcript).map_err(|e| format!("{}: {e}", transcript.display()))?;
669 let mut cmd = Command::new(&opts.harness);
670 cmd.arg("-p")
671 .arg(prompt)
672 .args(args)
673 .current_dir(ws)
674 .stdin(Stdio::null())
675 .stdout(file)
676 .stderr(Stdio::null());
677 clean_env(&mut cmd, bin);
678 let started = Instant::now();
679 let (mut child, _group) =
680 spawn_group(&mut cmd).map_err(|e| format!("{}: {e}", opts.harness.display()))?;
681 let timed_out = wait_bounded(&mut child, opts.session_timeout)?.is_none();
682 let duration_ms = started.elapsed().as_millis() as u64;
683 let text = std::fs::read_to_string(transcript).unwrap_or_default();
684 let facts = usage::read_stream(&text).unwrap_or_default();
685 let (ended, is_error) = if timed_out {
686 ("timeout".to_string(), true)
687 } else if facts.ended.is_empty() {
688 ("no_result".to_string(), true)
689 } else {
690 (facts.ended, facts.is_error || facts.requests.is_empty())
691 };
692 Ok(EconomicsSession {
693 index,
694 prompt_sha256: crate::policy::sha256_hex(prompt),
695 duration_ms,
696 turns: facts.turns,
697 ended,
698 is_error,
699 requests: facts.requests,
700 models: facts.models,
701 reported_cost_microusd: facts.reported_cost_microusd,
702 tools: facts.tools,
703 orientation: facts.orientation,
704 })
705}
706
707fn sessions_check(sessions: &[EconomicsSession]) -> EconomicsCheck {
712 let unfinished: Vec<String> = sessions
713 .iter()
714 .filter(|s| s.ended != "success" || s.is_error)
715 .map(|s| {
716 format!(
717 "session {} ended {}{}",
718 s.index,
719 s.ended,
720 if s.is_error { " (error)" } else { "" }
721 )
722 })
723 .collect();
724 let passed = !sessions.is_empty() && unfinished.is_empty();
725 EconomicsCheck {
726 id: "sessions_completed".into(),
727 passed,
728 detail: if sessions.is_empty() {
729 "no session ran".into()
730 } else if passed {
731 format!(
732 "all {} session(s) ended with the harness's success result",
733 sessions.len()
734 )
735 } else {
736 unfinished.join("; ")
737 },
738 }
739}
740
741fn verify(ws: &Path, command: &str, limit: Duration) -> (bool, String) {
745 let mut cmd = Command::new("sh");
746 cmd.args(["-c", command])
747 .current_dir(ws)
748 .stdin(Stdio::null())
749 .stdout(Stdio::null())
750 .stderr(Stdio::piped());
751 clean_env(&mut cmd, None);
752 let (mut child, _group) = match spawn_group(&mut cmd) {
753 Ok(c) => c,
754 Err(e) => return (false, format!("could not run: {e}")),
755 };
756 let mut stderr = child.stderr.take();
759 let (tx, rx) = mpsc::channel();
760 std::thread::spawn(move || {
761 let mut text = Vec::new();
762 if let Some(e) = stderr.as_mut() {
763 let _ = e.read_to_end(&mut text);
764 }
765 let _ = tx.send(text);
766 });
767 match wait_bounded(&mut child, limit) {
768 Err(e) => (false, format!("could not wait: {e}")),
769 Ok(None) => (false, format!("timed out after {}s", limit.as_secs())),
770 Ok(Some(status)) => {
771 let text = rx.recv_timeout(Duration::from_secs(5)).unwrap_or_default();
772 let tail = String::from_utf8_lossy(&text)
773 .lines()
774 .rev()
775 .find(|l| !l.trim().is_empty())
776 .unwrap_or("")
777 .to_string();
778 (
779 status.success(),
780 format!("exit {}: {tail}", status.code().unwrap_or(-1)),
781 )
782 }
783 }
784}
785
786fn changed_files(ws: &Path, base: &str) -> Vec<String> {
789 let _ = git(ws, &["add", "-A"]);
790 let out = git(ws, &["diff", "--cached", "--name-only", base]).unwrap_or_default();
791 let mut files: Vec<String> = out
792 .lines()
793 .filter(|l| !l.starts_with(".ai/local/") && !l.contains("__pycache__"))
794 .map(str::to_string)
795 .collect();
796 crate::order::canonical(&mut files);
797 files
798}
799
800fn run_job(
804 opts: &RunOptions,
805 decl: &Declarations,
806 suite: &EconomicsSuite,
807 job: &Job,
808 log: &dyn Fn(String),
809) -> Result<EconomicsRun, String> {
810 let id = run_id(&job.task.id, &job.variant, job.repetition);
811 let treatment = Some(&job.variant) == suite.treatment.as_ref();
812 let model = suite.model.clone().ok_or("the suite names no model")?;
813 let repository = super::revision(&opts.root)?;
816 let inputs_at_start = inputs_digest(&opts.root, &suite.freshness_inputs)?;
817 let harness_at_start = harness_version(&opts.harness);
818 let task_dir = opts.root.join(DIR).join("tasks").join(&job.task.id);
819 let mut extra: Vec<PathBuf> = vec![task_dir.join("task.yaml")];
820 extra.extend(job.task.sessions.iter().map(|s| task_dir.join(&s.prompt)));
821 if let Some(s) = &job.task.setup {
822 extra.push(opts.root.join(s));
823 }
824 let extra_refs: Vec<&Path> = extra.iter().map(PathBuf::as_path).collect();
825 let fixture_digest =
826 sha256_file_tree(&opts.root, &opts.root.join(&job.task.fixture), &extra_refs);
827
828 let workspace = Workspace::create(&id)?;
829 let ws = workspace.repo();
830 let base_commit = prepare(opts, job, treatment, &ws)?;
831 let fixture_tests: BTreeSet<String> = {
832 let mut f = Vec::new();
833 walk(&ws.join("tests"), &ws.join("tests"), &mut f);
834 f.into_iter().collect()
835 };
836 let empty_mcp = workspace.mcp_config();
837 std::fs::write(&empty_mcp, "{\"mcpServers\":{}}\n").map_err(|e| e.to_string())?;
838 let args = harness_args(&model, suite.max_budget_usd_per_session, &empty_mcp);
839 let configuration_digest = configuration_digest(&args);
840 if opts.dry_run {
841 let short = &base_commit[..base_commit.len().min(12)];
842 log(format!(
843 "{id}: prepared a workspace at {short} ({} session(s)) and removed it again; \
844 would run: {} -p <prompt> {}",
845 job.task.sessions.len(),
846 opts.harness.display(),
847 args.join(" ")
848 ));
849 workspace.remove()?;
850 return Err("dry run".into());
851 }
852 let transcripts = opts.work_dir.join(&suite.id).join(&id);
853 std::fs::create_dir_all(&transcripts).map_err(|e| format!("{}: {e}", transcripts.display()))?;
854 let bin = treatment.then_some(opts.majordomus.as_path());
855 let started_at = crate::peers::rfc3339(SystemTime::now());
856 let mut sessions = Vec::new();
857 for (i, s) in job.task.sessions.iter().enumerate() {
858 let prompt = std::fs::read_to_string(task_dir.join(&s.prompt))
859 .map_err(|e| format!("{}: {e}", s.prompt))?;
860 let transcript = transcripts.join(format!("session-{}.jsonl", i + 1));
861 log(format!(
862 "{id}: session {} of {} started",
863 i + 1,
864 job.task.sessions.len()
865 ));
866 let sess = session(
867 opts,
868 &ws,
869 &transcript,
870 &args,
871 prompt.trim_end(),
872 (i + 1) as u32,
873 bin,
874 )?;
875 log(format!(
876 "{id}: session {} ended {} after {}s, {} request(s)",
877 i + 1,
878 sess.ended,
879 sess.duration_ms / 1000,
880 sess.requests.len()
881 ));
882 sessions.push(sess);
883 }
884 let changed = changed_files(&ws, &base_commit);
886 let (visible, visible_detail) = verify(&ws, &job.task.verify, VERIFY_LIMIT);
887 let acceptance_dir = opts.root.join(&job.task.acceptance);
888 let mut accepted = Vec::new();
889 walk(&acceptance_dir, &acceptance_dir, &mut accepted);
890 for f in &accepted {
891 let dst = ws.join("tests").join(f);
892 let _ = std::fs::create_dir_all(dst.parent().unwrap_or(&ws));
893 std::fs::copy(acceptance_dir.join(f), &dst).map_err(|e| format!("{f}: {e}"))?;
894 }
895 let (acceptance, acceptance_detail) = verify(&ws, &job.task.verify, VERIFY_LIMIT);
896 let missing: Vec<&String> = fixture_tests
897 .iter()
898 .filter(|f| !ws.join("tests").join(f).exists())
899 .collect();
900 let checks = vec![
901 sessions_check(&sessions),
902 EconomicsCheck {
903 id: "visible_tests".into(),
904 passed: visible,
905 detail: visible_detail,
906 },
907 EconomicsCheck {
908 id: "acceptance_tests".into(),
909 passed: acceptance,
910 detail: acceptance_detail,
911 },
912 EconomicsCheck {
913 id: "tests_kept".into(),
914 passed: missing.is_empty(),
915 detail: if missing.is_empty() {
916 format!("all {} fixture test file(s) present", fixture_tests.len())
917 } else {
918 format!(
919 "deleted: {}",
920 missing
921 .iter()
922 .map(|s| s.as_str())
923 .collect::<Vec<_>>()
924 .join(", ")
925 )
926 },
927 },
928 ];
929 let finished_at = crate::peers::rfc3339(SystemTime::now());
930 let inputs_at_end = inputs_digest(&opts.root, &suite.freshness_inputs)?;
932 if inputs_at_end != inputs_at_start {
933 return Err(format!(
934 "the suite's freshness inputs changed while the run was in flight (digest {} at \
935 the start, {} at the end): the checkout was edited, and nothing was recorded",
936 &inputs_at_start[..12.min(inputs_at_start.len())],
937 &inputs_at_end[..12.min(inputs_at_end.len())]
938 ));
939 }
940 let harness_at_end = harness_version(&opts.harness);
941 if harness_at_end != harness_at_start {
942 return Err(format!(
943 "the harness changed while the run was in flight ({harness_at_start} at the start, \
944 {harness_at_end} at the end): nothing was recorded"
945 ));
946 }
947 if let Err(e) = workspace.remove() {
948 log(format!(
949 "{id}: WARN the workspace could not be removed: {e}"
950 ));
951 }
952 let models_reported: Vec<String> = sessions
953 .iter()
954 .flat_map(|s| {
955 s.models
956 .iter()
957 .map(|m| m.model.clone())
958 .chain(s.requests.iter().map(|r| r.model.clone()))
959 })
960 .collect::<BTreeSet<_>>()
961 .into_iter()
962 .collect();
963 Ok(EconomicsRun {
964 schema: RUN_SCHEMA.into(),
965 id,
966 suite: suite.id.clone(),
967 suite_version: suite.version,
968 methodology: decl.methodology.version,
969 task: job.task.id.clone(),
970 variant: job.variant.clone(),
971 repetition: job.repetition,
972 provider: suite.provider.clone().unwrap_or_default(),
973 harness: EconomicsHarness {
974 name: suite.harness.clone().unwrap_or_default(),
975 version: harness_at_start,
976 },
977 model_requested: model,
978 models_reported,
979 repository,
980 majordomus_version: crate::VERSION.into(),
981 fixture_digest,
982 inputs_digest: inputs_at_start,
983 configuration_digest,
984 started_at,
985 finished_at,
986 outcome: EconomicsOutcome {
987 completed: checks.iter().all(|c| c.passed),
988 checks,
989 changed_files: changed,
990 },
991 sessions,
992 })
993}
994
995pub fn record_path(root: &Path, suite: &str, id: &str) -> PathBuf {
1008 root.join(DIR)
1009 .join("runs")
1010 .join(suite)
1011 .join(format!("{id}.json"))
1012}
1013
1014fn write_record(path: &Path, run: &EconomicsRun) -> Result<(), String> {
1018 std::fs::create_dir_all(path.parent().unwrap_or(Path::new("."))).map_err(|e| e.to_string())?;
1019 let mut text = serde_json::to_string_pretty(run).map_err(|e| e.to_string())?;
1020 text.push('\n');
1021 let mut f = std::fs::OpenOptions::new()
1022 .write(true)
1023 .create_new(true)
1024 .open(path)
1025 .map_err(|e| {
1026 if e.kind() == std::io::ErrorKind::AlreadyExists {
1027 format!(
1028 "{} is already recorded; {NEVER_OVERWRITTEN}",
1029 path.display()
1030 )
1031 } else {
1032 format!("{}: {e}", path.display())
1033 }
1034 })?;
1035 if let Err(e) = f.write_all(text.as_bytes()).and_then(|()| f.sync_all()) {
1036 drop(f);
1037 let _ = std::fs::remove_file(path);
1038 return Err(format!("{}: {e}", path.display()));
1039 }
1040 Ok(())
1041}
1042
1043fn outside(root: &Path, dir: &Path, what: &str) -> Result<PathBuf, String> {
1048 let root = root
1049 .canonicalize()
1050 .map_err(|e| format!("{}: {e}", root.display()))?;
1051 let dir = if dir.is_absolute() {
1052 dir.to_path_buf()
1053 } else {
1054 std::env::current_dir()
1055 .map_err(|e| e.to_string())?
1056 .join(dir)
1057 };
1058 let created: Vec<PathBuf> = dir
1059 .ancestors()
1060 .take_while(|p| !p.exists())
1061 .map(Path::to_path_buf)
1062 .collect();
1063 std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
1064 let canonical = dir
1065 .canonicalize()
1066 .map_err(|e| format!("{}: {e}", dir.display()))?;
1067 if canonical.starts_with(&root) {
1068 for p in &created {
1069 if std::fs::remove_dir(p).is_err() {
1070 break;
1071 }
1072 }
1073 return Err(format!(
1074 "{what} must be outside the repository: transcripts never enter it"
1075 ));
1076 }
1077 Ok(canonical)
1078}
1079
1080pub fn run(
1125 opts: &RunOptions,
1126 decl: &Declarations,
1127 log: &(dyn Fn(String) + Sync),
1128) -> Result<(Vec<PathBuf>, Vec<String>), String> {
1129 if opts.force {
1130 return Err(NEVER_OVERWRITTEN.into());
1131 }
1132 let suite = decl
1133 .suites
1134 .get(&opts.suite)
1135 .ok_or_else(|| format!("no suite {:?}", opts.suite))?
1136 .clone();
1137 if suite.kind != "live" {
1138 return Err(format!(
1139 "suite {} is a {} suite; `economics measure` runs it",
1140 suite.id, suite.kind
1141 ));
1142 }
1143 let work_dir = outside(&opts.root, &opts.work_dir, "the work directory")?;
1144 outside(
1146 &opts.root,
1147 &std::env::temp_dir(),
1148 "the system temporary directory (TMPDIR)",
1149 )?;
1150 let opts = &RunOptions {
1151 work_dir,
1152 ..opts.clone()
1153 };
1154 let variants: Vec<String> = [suite.control.clone(), suite.treatment.clone()]
1155 .into_iter()
1156 .flatten()
1157 .collect();
1158 let mut jobs = Vec::new();
1159 for rep in 1..=suite.repetitions.unwrap_or(1) {
1160 if !opts.repetitions.is_empty() && !opts.repetitions.contains(&rep) {
1161 continue;
1162 }
1163 for t in &suite.tasks {
1164 if !opts.tasks.is_empty() && !opts.tasks.contains(t) {
1165 continue;
1166 }
1167 let task = decl
1168 .tasks
1169 .get(t)
1170 .ok_or_else(|| format!("no task {t}"))?
1171 .clone();
1172 for v in &variants {
1173 if !opts.dry_run && record_path(&opts.root, &suite.id, &run_id(t, v, rep)).exists()
1174 {
1175 continue;
1176 }
1177 jobs.push(Job {
1178 task: task.clone(),
1179 variant: v.clone(),
1180 repetition: rep,
1181 });
1182 }
1183 }
1184 }
1185 log(format!(
1186 "{}: {} run(s) to do, {} at a time",
1187 suite.id,
1188 jobs.len(),
1189 opts.parallel.max(1)
1190 ));
1191 let queue = Arc::new(Mutex::new(
1192 jobs.into_iter().collect::<std::collections::VecDeque<_>>(),
1193 ));
1194 let (tx, rx) = mpsc::channel::<Result<String, String>>();
1195 std::thread::scope(|scope| {
1196 for _ in 0..opts.parallel.max(1) {
1197 let queue = Arc::clone(&queue);
1198 let tx = tx.clone();
1199 let suite = &suite;
1200 scope.spawn(move || loop {
1201 let job = queue.lock().ok().and_then(|mut q| q.pop_front());
1202 let Some(job) = job else { break };
1203 let id = run_id(&job.task.id, &job.variant, job.repetition);
1204 let result = run_job(opts, decl, suite, &job, log).and_then(|run| {
1205 let path = record_path(&opts.root, &suite.id, &run.id);
1206 write_record(&path, &run)?;
1207 log(format!(
1208 "{id}: recorded, completed={}",
1209 run.outcome.completed
1210 ));
1211 Ok(run.id)
1212 });
1213 let _ = tx.send(result.map_err(|e| format!("{id}: {e}")));
1214 });
1215 }
1216 });
1217 drop(tx);
1218 let mut recorded = Vec::new();
1219 let mut failed = Vec::new();
1220 for r in rx {
1221 match r {
1222 Ok(id) => recorded.push(id),
1223 Err(e) => failed.push(e),
1224 }
1225 }
1226 crate::order::canonical(&mut recorded);
1229 crate::order::canonical(&mut failed);
1230 let written = recorded
1231 .iter()
1232 .map(|id| record_path(&opts.root, &suite.id, id))
1233 .collect();
1234 Ok((written, failed))
1235}
1236
1237pub fn check_references(
1277 root: &Path,
1278 decl: &Declarations,
1279 work_dir: &Path,
1280) -> Result<Vec<String>, Vec<String>> {
1281 let mut ok = Vec::new();
1282 let mut bad = Vec::new();
1283 let mut by: BTreeMap<&str, &EconomicsTask> = BTreeMap::new();
1284 for t in decl.tasks.values() {
1285 by.insert(&t.id, t);
1286 }
1287 for (id, task) in by {
1288 let ws = work_dir.join("references").join(id);
1289 let opts = RunOptions {
1290 root: root.to_path_buf(),
1291 suite: String::new(),
1292 tasks: Vec::new(),
1293 repetitions: Vec::new(),
1294 work_dir: work_dir.to_path_buf(),
1295 harness: PathBuf::new(),
1296 majordomus: PathBuf::new(),
1297 parallel: 1,
1298 force: false,
1299 dry_run: false,
1300 session_timeout: Duration::from_secs(1),
1301 };
1302 let job = Job {
1303 task: task.clone(),
1304 variant: String::new(),
1305 repetition: 0,
1306 };
1307 if let Err(e) = prepare(&opts, &job, false, &ws) {
1308 bad.push(format!("{id}: {e}"));
1309 let _ = std::fs::remove_dir_all(&ws);
1310 continue;
1311 }
1312 let acceptance_dir = root.join(&task.acceptance);
1313 let mut files = Vec::new();
1314 walk(&acceptance_dir, &acceptance_dir, &mut files);
1315 for f in &files {
1316 let _ = std::fs::copy(acceptance_dir.join(f), ws.join("tests").join(f));
1317 }
1318 let (before, _) = verify(&ws, &task.verify, VERIFY_LIMIT);
1319 let applied = git(
1320 &ws,
1321 &["apply", &root.join(&task.reference).to_string_lossy()],
1322 );
1323 let (after, detail) = verify(&ws, &task.verify, VERIFY_LIMIT);
1324 let _ = std::fs::remove_dir_all(&ws);
1325 match (before, applied, after) {
1326 (false, Ok(_), true) => ok.push(format!(
1327 "{id}: fails on the starting state, passes on the reference"
1328 )),
1329 (true, _, _) => bad.push(format!(
1330 "{id}: the hidden tests already pass on the starting state"
1331 )),
1332 (_, Err(e), _) => bad.push(format!("{id}: the reference does not apply: {e}")),
1333 (_, _, false) => bad.push(format!("{id}: the reference does not pass: {detail}")),
1334 }
1335 }
1336 if bad.is_empty() {
1337 Ok(ok)
1338 } else {
1339 Err(bad)
1340 }
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345 use super::*;
1346 use serde_json::json;
1347
1348 fn methodology() -> EconomicsMethodology {
1349 serde_json::from_value(json!({
1350 "schema": "economics-methodology/v1", "version": 1, "title": "t", "question": "q",
1351 "unit": "tokens", "primary_metric": "m", "classes": [], "variants": [],
1352 "success": [], "pairing": { "key": [], "comparable": [], "valid": "v" },
1353 "statistics": { "per_pair": "p", "location": "median", "interval": "i",
1354 "confidence_bp": 9500, "resamples": 1, "seed": 1, "min_pairs_for_interval": 1 },
1355 "publication": { "min_valid_pairs": 1, "min_categories": 1,
1356 "min_pairs_per_category": 1, "min_repetitions": 1, "min_valid_pair_rate_bp": 1,
1357 "max_interval_width_bp": 1, "require_current": true },
1358 "outliers": { "rule": "r" }
1359 }))
1360 .unwrap()
1361 }
1362
1363 fn suite(kind: &str, tasks: &[&str], treatment: Option<&str>) -> EconomicsSuite {
1364 serde_json::from_value(json!({
1365 "schema": "economics-suite/v1", "id": "pilot", "version": 1, "kind": kind,
1366 "title": "Pilot", "model": "claude-test", "control": "baseline",
1367 "treatment": treatment, "tasks": tasks, "freshness_inputs": []
1368 }))
1369 .unwrap()
1370 }
1371
1372 fn task(id: &str, verify: &str) -> EconomicsTask {
1373 serde_json::from_value(json!({
1374 "schema": "economics-task/v1", "id": id, "title": id, "category": "c",
1375 "complexity": "small", "source": "s", "fixture": "fx", "acceptance": "hidden",
1376 "reference": "ref.patch", "verify": verify, "scope": [],
1377 "sessions": [{ "prompt": "prompt-1.md" }]
1378 }))
1379 .unwrap()
1380 }
1381
1382 fn declarations(suite: EconomicsSuite, tasks: Vec<EconomicsTask>) -> Declarations {
1383 Declarations {
1384 methodology: methodology(),
1385 suites: [(suite.id.clone(), suite)].into(),
1386 tasks: tasks.into_iter().map(|t| (t.id.clone(), t)).collect(),
1387 }
1388 }
1389
1390 fn options(root: &Path, work_dir: &Path) -> RunOptions {
1391 RunOptions {
1392 root: root.into(),
1393 suite: "pilot".into(),
1394 tasks: Vec::new(),
1395 repetitions: Vec::new(),
1396 work_dir: work_dir.into(),
1397 harness: PathBuf::from("/nonexistent/harness"),
1398 majordomus: PathBuf::from("/nonexistent/majordomus"),
1399 parallel: 2,
1400 force: false,
1401 dry_run: false,
1402 session_timeout: Duration::from_secs(1),
1403 }
1404 }
1405
1406 fn write(path: &Path, text: &str) {
1407 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1408 std::fs::write(path, text).unwrap();
1409 }
1410
1411 fn files(dir: &Path) -> Vec<String> {
1412 let mut out = Vec::new();
1413 walk(dir, dir, &mut out);
1414 out.sort();
1415 out
1416 }
1417
1418 fn repository(root: &Path, id: &str) {
1422 write(&root.join("fx/app.py"), "x = 1\n");
1423 write(&root.join("fx/tests/test_app.py"), "assert True\n");
1424 write(&root.join("hidden/test_hidden.py"), "assert solved\n");
1425 write(
1426 &root.join(DIR).join("tasks").join(id).join("prompt-1.md"),
1427 "Solve it.\n",
1428 );
1429 git(root, &["init", "-q"]).unwrap();
1430 git(root, &["add", "-A"]).unwrap();
1431 git(
1432 root,
1433 &[
1434 "-c",
1435 "user.email=t@example.com",
1436 "-c",
1437 "user.name=t",
1438 "commit",
1439 "-qm",
1440 "i",
1441 ],
1442 )
1443 .unwrap();
1444 }
1445
1446 fn harness(dir: &Path, body: &str, result: &str) -> PathBuf {
1449 let path = dir.join("harness");
1450 let request = r#"{"type":"assistant","message":{"id":"m1","model":"claude-test","#
1451 .to_string()
1452 + r#""content":[],"usage":{"input_tokens":10,"output_tokens":5}}}"#;
1453 let script = format!(
1454 "#!/bin/sh\n\
1455 if [ \"$1\" = \"--version\" ]; then echo '1.2.3 (Stand-in)'; exit 0; fi\n\
1456 {body}\necho '{request}'\necho '{result}'\n"
1457 );
1458 write(&path, &script);
1459 #[cfg(unix)]
1460 {
1461 use std::os::unix::fs::PermissionsExt;
1462 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1463 }
1464 path
1465 }
1466
1467 fn ended(pid: &str) -> bool {
1470 let deadline = Instant::now() + Duration::from_secs(5);
1471 loop {
1472 let out = Command::new("ps")
1473 .args(["-o", "stat=", "-p", pid])
1474 .output()
1475 .unwrap();
1476 let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
1477 if stat.is_empty() || stat.starts_with('Z') {
1478 return true;
1479 }
1480 if Instant::now() >= deadline {
1481 return false;
1482 }
1483 std::thread::sleep(Duration::from_millis(50));
1484 }
1485 }
1486
1487 const SUCCESS: &str = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":1}"#;
1488
1489 const CREATE_SOLVED: &str = "--- /dev/null\n+++ b/solved\n@@ -0,0 +1 @@\n+yes\n";
1490
1491 fn record(root: &Path, id: &str) -> EconomicsRun {
1492 let text = std::fs::read_to_string(record_path(root, "pilot", id)).unwrap();
1493 serde_json::from_str(&text).unwrap()
1494 }
1495
1496 #[test]
1497 fn walk_skips_bytecode_caches_and_git_metadata() {
1498 let dir = tempfile::tempdir().unwrap();
1499 let d = dir.path();
1500 write(&d.join("app.py"), "x = 1\n");
1501 write(&d.join("pkg/mod.py"), "y = 2\n");
1502 write(&d.join("pkg/mod.pyc"), "\0");
1503 write(&d.join("__pycache__/app.cpython-312.pyc"), "\0");
1504 write(&d.join("pkg/__pycache__/notes.txt"), "cached");
1505 write(&d.join(".git/HEAD"), "ref: refs/heads/main\n");
1506 write(&d.join("tests/test_app.py"), "def test_app(): pass\n");
1507 assert_eq!(files(d), ["app.py", "pkg/mod.py", "tests/test_app.py"]);
1508 assert!(
1509 files(&d.join("absent")).is_empty(),
1510 "a missing directory holds nothing"
1511 );
1512 }
1513
1514 #[test]
1515 fn a_fixture_digests_the_same_in_every_checkout_and_changes_with_one_byte() {
1516 let (a, b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1517 for repo in [a.path(), b.path()] {
1518 write(&repo.join("fx/app.py"), "x = 1\n");
1519 write(&repo.join("fx/tests/test_app.py"), "assert True\n");
1520 write(&repo.join("tasks/t/task.yaml"), "id: t\n");
1521 }
1522 let digest = |repo: &Path| {
1523 sha256_file_tree(repo, &repo.join("fx"), &[&repo.join("tasks/t/task.yaml")])
1524 };
1525 let first = digest(a.path());
1526 assert_eq!(first.len(), 64, "a hex SHA-256");
1527 assert_eq!(
1528 first,
1529 digest(b.path()),
1530 "the checkout's location is not part of the digest"
1531 );
1532
1533 write(&b.path().join("fx/__pycache__/app.cpython-312.pyc"), "\0");
1534 assert_eq!(
1535 first,
1536 digest(b.path()),
1537 "bytecode a run leaves behind is not the fixture"
1538 );
1539
1540 write(&b.path().join("fx/app.py"), "x = 2\n");
1541 assert_ne!(first, digest(b.path()), "one changed byte in the fixture");
1542
1543 write(&b.path().join("fx/app.py"), "x = 1\n");
1544 write(&b.path().join("tasks/t/task.yaml"), "id: u\n");
1545 assert_ne!(
1546 first,
1547 digest(b.path()),
1548 "one changed byte in a declaration file"
1549 );
1550 }
1551
1552 #[test]
1553 fn the_harness_is_shut_off_from_user_configuration_and_given_the_budget() {
1554 let mcp = Path::new("/tmp/mj-bench-0123456789ab/mcp.json");
1555 let args = harness_args("claude-test", Some(5), mcp);
1556 for flag in [
1557 "--strict-mcp-config",
1558 "--disable-slash-commands",
1559 "--no-session-persistence",
1560 ] {
1561 assert!(
1562 args.iter().any(|a| a == flag),
1563 "{flag} missing from {args:?}"
1564 );
1565 }
1566 let after = |flag: &str| {
1567 let i = args.iter().position(|a| a == flag)?;
1568 args.get(i + 1).map(String::as_str)
1569 };
1570 assert_eq!(after("--setting-sources"), Some("project,local"));
1571 assert_eq!(
1572 after("--mcp-config"),
1573 Some("/tmp/mj-bench-0123456789ab/mcp.json")
1574 );
1575 assert_eq!(after("--model"), Some("claude-test"));
1576 assert_eq!(after("--output-format"), Some("stream-json"));
1577 assert_eq!(after("--max-budget-usd"), Some("5"));
1578
1579 let unbounded = harness_args("claude-test", None, mcp);
1580 assert!(!unbounded.iter().any(|a| a == "--max-budget-usd"));
1581 assert_eq!(
1582 unbounded.len() + 2,
1583 args.len(),
1584 "the budget is the only difference"
1585 );
1586
1587 let other = harness_args(
1589 "claude-test",
1590 Some(5),
1591 Path::new("/tmp/mj-bench-ba9876543210/mcp.json"),
1592 );
1593 assert_eq!(configuration_digest(&args), configuration_digest(&other));
1594 assert_ne!(
1595 configuration_digest(&args),
1596 configuration_digest(&unbounded)
1597 );
1598 assert_ne!(
1599 configuration_digest(&args),
1600 configuration_digest(&harness_args("claude-other", Some(5), mcp))
1601 );
1602 }
1603
1604 #[test]
1605 fn only_what_ties_a_child_to_the_callers_session_or_repository_is_withheld() {
1606 for name in [
1607 "CLAUDECODE",
1608 "CLAUDE_CODE_ENTRYPOINT",
1609 "CLAUDE_CODE_SESSION_ID",
1610 "CLAUDE_CODE_SSE_PORT",
1611 "CLAUDE_PROJECT_DIR",
1612 "MAJORDOMUS_SHARE",
1613 "MAJORDOMUS_ROOT",
1614 "MJ_TASK",
1615 "CARGO_TARGET_DIR",
1616 "CARGO_BUILD_TARGET_DIR",
1617 "GIT_DIR",
1618 "GIT_WORK_TREE",
1619 "GIT_INDEX_FILE",
1620 ] {
1621 assert!(tied(name), "{name} ties the child to the caller");
1622 }
1623 for name in [
1625 "ANTHROPIC_API_KEY",
1626 "ANTHROPIC_BASE_URL",
1627 "CLAUDE_CODE_OAUTH_TOKEN",
1628 "CLAUDE_CODE_USE_BEDROCK",
1629 "CLAUDE_CODE_USE_VERTEX",
1630 "AWS_REGION",
1631 "HOME",
1632 "PATH",
1633 "CARGO_HOME",
1634 ] {
1635 assert!(
1636 !tied(name),
1637 "{name} is not the caller's session or repository"
1638 );
1639 }
1640 }
1641
1642 #[test]
1643 fn a_child_never_inherits_the_callers_session_or_repository() {
1644 let removed = ["MAJORDOMUS_RUNNER_TEST_PROBE", "MJ_RUNNER_TEST_PROBE"];
1645 for name in removed {
1646 std::env::set_var(name, "1");
1647 }
1648 std::env::set_var("CLAUDE_CODE_RUNNER_TEST_PROBE", "1");
1649
1650 let mut cmd = Command::new("true");
1651 clean_env(&mut cmd, Some(Path::new("/opt/majordomus/bin/majordomus")));
1652 let envs: BTreeMap<String, Option<String>> = cmd
1653 .get_envs()
1654 .map(|(k, v)| {
1655 (
1656 k.to_string_lossy().into_owned(),
1657 v.map(|v| v.to_string_lossy().into_owned()),
1658 )
1659 })
1660 .collect();
1661 let mut bare = Command::new("true");
1662 clean_env(&mut bare, None);
1663 let bare_path = bare.get_envs().any(|(k, _)| k == "PATH");
1664 let mut relative = Command::new("true");
1665 clean_env(&mut relative, Some(Path::new("majordomus")));
1666 let relative_path = relative.get_envs().any(|(k, _)| k == "PATH");
1667
1668 for name in removed {
1670 std::env::remove_var(name);
1671 }
1672 std::env::remove_var("CLAUDE_CODE_RUNNER_TEST_PROBE");
1673
1674 for name in removed {
1675 assert_eq!(
1676 envs.get(name),
1677 Some(&None),
1678 "{name} must be removed from the child"
1679 );
1680 }
1681 assert!(
1682 !envs.contains_key("CLAUDE_CODE_RUNNER_TEST_PROBE"),
1683 "a CLAUDE_CODE_ variable that is not the session's is inherited as is"
1684 );
1685 let path = envs["PATH"].as_deref().unwrap();
1686 assert!(
1687 path.starts_with("/opt/majordomus/bin:"),
1688 "the installed executable first: {path}"
1689 );
1690 assert_eq!(envs["PYTHONDONTWRITEBYTECODE"].as_deref(), Some("1"));
1691 assert_eq!(
1692 envs["DISABLE_AUTOUPDATER"].as_deref(),
1693 Some("1"),
1694 "the harness stays put"
1695 );
1696 assert!(
1697 !bare_path,
1698 "the control is given no executable: PATH is left alone"
1699 );
1700 assert!(
1701 !relative_path,
1702 "an executable with no directory never puts `` on PATH"
1703 );
1704 }
1705
1706 #[test]
1707 fn run_refuses_a_work_directory_inside_the_repository() {
1708 let (repo, work) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1709 let decl = declarations(suite("live", &[], Some("majordomus")), Vec::new());
1710
1711 let inside = options(repo.path(), &repo.path().join("tmp/economics"));
1712 let err = run(&inside, &decl, &|_| {}).unwrap_err();
1713 assert!(err.contains("outside the repository"), "{err}");
1714 assert!(
1715 !repo.path().join("tmp").exists(),
1716 "what the check created is taken back"
1717 );
1718
1719 let outside = options(repo.path(), work.path());
1720 assert_eq!(
1721 run(&outside, &decl, &|_| {}).unwrap(),
1722 (Vec::new(), Vec::new())
1723 );
1724 }
1725
1726 #[cfg(unix)]
1727 #[test]
1728 fn a_symbolic_link_cannot_put_the_work_directory_inside_the_repository() {
1729 let (repo, other) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1730 let decl = declarations(suite("live", &[], Some("majordomus")), Vec::new());
1731 let into = other.path().join("into-repo");
1733 std::os::unix::fs::symlink(repo.path(), &into).unwrap();
1734 let err = run(&options(repo.path(), &into.join("work")), &decl, &|_| {}).unwrap_err();
1735 assert!(err.contains("outside the repository"), "{err}");
1736 assert!(
1737 !repo.path().join("work").exists(),
1738 "taken back through the link as well"
1739 );
1740 let root_link = other.path().join("repo-link");
1742 std::os::unix::fs::symlink(repo.path(), &root_link).unwrap();
1743 let err = run(&options(&root_link, &repo.path().join("w")), &decl, &|_| {}).unwrap_err();
1744 assert!(err.contains("outside the repository"), "{err}");
1745 std::fs::create_dir(repo.path().join("kept")).unwrap();
1747 assert!(run(
1748 &options(repo.path(), &repo.path().join("kept")),
1749 &decl,
1750 &|_| {}
1751 )
1752 .is_err());
1753 assert!(repo.path().join("kept").is_dir());
1754 }
1755
1756 #[test]
1757 fn run_refuses_an_unknown_suite_a_context_suite_and_an_undeclared_task() {
1758 let (repo, work) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1759 let mut opts = options(repo.path(), work.path());
1760
1761 let live = declarations(suite("live", &[], None), Vec::new());
1762 opts.suite = "nightly".into();
1763 assert_eq!(
1764 run(&opts, &live, &|_| {}).unwrap_err(),
1765 "no suite \"nightly\""
1766 );
1767
1768 opts.suite = "pilot".into();
1769 let context = declarations(suite("context", &[], None), Vec::new());
1770 let err = run(&opts, &context, &|_| {}).unwrap_err();
1771 assert!(
1772 err.contains("is a context suite") && err.contains("economics measure"),
1773 "{err}"
1774 );
1775
1776 let ghost = declarations(suite("live", &["ghost"], None), Vec::new());
1777 assert_eq!(run(&opts, &ghost, &|_| {}).unwrap_err(), "no task ghost");
1778 }
1779
1780 #[test]
1781 fn force_is_refused_before_anything_happens() {
1782 let (repo, work) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1783 let decl = declarations(suite("live", &["t"], None), vec![task("t", "true")]);
1784 let mut opts = options(repo.path(), &work.path().join("w"));
1785 opts.force = true;
1786 let err = run(&opts, &decl, &|_| {}).unwrap_err();
1787 assert!(
1788 err.contains("never overwritten") && err.contains("methodology"),
1789 "{err}"
1790 );
1791 assert!(
1792 !work.path().join("w").exists(),
1793 "refused before the work directory exists"
1794 );
1795 }
1796
1797 #[test]
1798 fn a_record_is_created_once_and_never_written_over() {
1799 let repo = tempfile::tempdir().unwrap();
1800 let path = record_path(repo.path(), "pilot", "t--baseline--r1");
1801 let run: EconomicsRun = serde_json::from_value(json!({
1802 "schema": "economics-run/v1", "id": "t--baseline--r1", "suite": "pilot",
1803 "suite_version": 1, "methodology": 1, "task": "t", "variant": "baseline",
1804 "repetition": 1, "provider": "anthropic",
1805 "harness": {"name": "claude-code", "version": "0"}, "model_requested": "m",
1806 "models_reported": ["m"], "repository": {"commit": "0", "dirty": false},
1807 "majordomus_version": "0", "fixture_digest": "", "inputs_digest": "",
1808 "configuration_digest": "", "started_at": "", "finished_at": "", "sessions": [],
1809 "outcome": {"completed": true, "checks": [], "changed_files": []}
1810 }))
1811 .unwrap();
1812 write_record(&path, &run).unwrap();
1813 let first = std::fs::read_to_string(&path).unwrap();
1814 let mut other = run.clone();
1815 other.outcome.completed = false;
1816 let err = write_record(&path, &other).unwrap_err();
1817 assert!(
1818 err.contains("already recorded") && err.contains("never overwritten"),
1819 "{err}"
1820 );
1821 assert_eq!(
1822 std::fs::read_to_string(&path).unwrap(),
1823 first,
1824 "the record is untouched"
1825 );
1826 }
1827
1828 #[test]
1829 fn a_recorded_run_is_not_run_again() {
1830 let (repo, work) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1831 let decl = declarations(
1832 suite("live", &["t"], Some("majordomus")),
1833 vec![task("t", "true")],
1834 );
1835 for variant in ["baseline", "majordomus"] {
1836 write(
1837 &record_path(repo.path(), "pilot", &run_id("t", variant, 1)),
1838 "{}\n",
1839 );
1840 }
1841 let opts = options(repo.path(), work.path());
1842 let log = Mutex::new(Vec::new());
1843 let (written, failed) = run(&opts, &decl, &|l| log.lock().unwrap().push(l)).unwrap();
1844 assert!(written.is_empty() && failed.is_empty());
1845 assert_eq!(
1846 log.into_inner().unwrap(),
1847 ["pilot: 0 run(s) to do, 2 at a time"]
1848 );
1849 assert!(
1850 !work.path().join("pilot").exists(),
1851 "no transcript directory was made"
1852 );
1853 }
1854
1855 #[test]
1856 fn the_control_starts_from_the_committed_fixture() {
1857 let (repo, ws) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1858 write(&repo.path().join("fx/app.py"), "x = 1\n");
1859 write(&repo.path().join("fx/tests/test_app.py"), "assert True\n");
1860 write(
1861 &repo.path().join("fx/__pycache__/app.cpython-312.pyc"),
1862 "\0",
1863 );
1864 let job = Job {
1865 task: task("t", "true"),
1866 variant: "baseline".into(),
1867 repetition: 1,
1868 };
1869 let at = ws.path().join("repo");
1870 let head = prepare(&options(repo.path(), ws.path()), &job, false, &at).unwrap();
1871 assert_eq!(head.len(), 40, "a commit: {head}");
1872 assert_eq!(files(&at), ["app.py", "tests/test_app.py"]);
1873 let status = git(&at, &["status", "--porcelain"]).unwrap();
1874 assert_eq!(
1875 status, "",
1876 "the fixture is committed: the control starts from a clean tree"
1877 );
1878 let history = git(&at, &["log", "--format=%an <%ae> %cn <%ce> %s"]).unwrap();
1880 assert_eq!(
1881 history.trim(),
1882 "Developer <developer@example.invalid> Developer <developer@example.invalid> \
1883 Initial commit"
1884 );
1885 }
1886
1887 #[test]
1888 fn a_dry_run_prepares_a_workspace_runs_nothing_and_leaves_nothing() {
1889 let (repo, work) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1890 repository(repo.path(), "t");
1891 let decl = declarations(suite("live", &["t"], None), vec![task("t", "true")]);
1892 let mut opts = options(repo.path(), work.path());
1893 opts.dry_run = true;
1894 let log = Mutex::new(Vec::new());
1895 let (written, failed) = run(&opts, &decl, &|l| log.lock().unwrap().push(l)).unwrap();
1896 assert!(written.is_empty());
1897 assert_eq!(failed, ["t--baseline--r1: dry run"]);
1898 let log = log.into_inner().unwrap();
1899 let plan = log
1900 .iter()
1901 .find(|l| l.contains("would run:"))
1902 .expect("the command is printed");
1903 assert!(plan.contains("--strict-mcp-config") && plan.contains("/nonexistent/harness"));
1904 assert!(plan.contains("prepared a workspace at ") && plan.contains("removed it again"));
1905 let mcp = plan
1906 .split_whitespace()
1907 .find(|w| w.ends_with("mcp.json"))
1908 .unwrap();
1909 assert!(
1910 !Path::new(mcp).parent().unwrap().exists(),
1911 "the workspace is gone: {mcp}"
1912 );
1913 assert!(
1914 !work.path().join("pilot").exists(),
1915 "no transcript directory for a dry run"
1916 );
1917 }
1918
1919 #[test]
1920 fn a_run_records_in_a_neutral_workspace_and_leaves_no_workspace_behind() {
1921 let (repo, work, probe) = (
1922 tempfile::tempdir().unwrap(),
1923 tempfile::tempdir().unwrap(),
1924 tempfile::tempdir().unwrap(),
1925 );
1926 let id = "distinctive-task";
1927 repository(repo.path(), id);
1928 let where_ = probe.path().join("where");
1929 let listing = probe.path().join("listing");
1930 let body = format!(
1931 "pwd > '{}'\nls -a .. > '{}'\necho yes > solved",
1932 where_.display(),
1933 listing.display()
1934 );
1935 let mut opts = options(repo.path(), work.path());
1936 opts.harness = harness(probe.path(), &body, SUCCESS);
1937 opts.session_timeout = Duration::from_secs(60);
1938 let decl = declarations(suite("live", &[id], None), vec![task(id, "test -f solved")]);
1939 let (written, failed) = run(&opts, &decl, &|_| {}).unwrap();
1940 assert!(failed.is_empty(), "{failed:?}");
1941 assert_eq!(
1942 written,
1943 [record_path(
1944 repo.path(),
1945 "pilot",
1946 "distinctive-task--baseline--r1"
1947 )]
1948 );
1949
1950 let r = record(repo.path(), "distinctive-task--baseline--r1");
1951 let ids: Vec<&str> = r.outcome.checks.iter().map(|c| c.id.as_str()).collect();
1952 assert_eq!(
1953 ids,
1954 [
1955 "sessions_completed",
1956 "visible_tests",
1957 "acceptance_tests",
1958 "tests_kept"
1959 ]
1960 );
1961 assert!(r.outcome.completed, "{:?}", r.outcome.checks);
1962 assert_eq!(r.sessions[0].ended, "success");
1963 assert_eq!(r.harness.version, "1.2.3");
1964 assert_eq!(
1965 r.repository.commit,
1966 git(repo.path(), &["rev-parse", "HEAD"]).unwrap().trim()
1967 );
1968 assert_eq!(r.inputs_digest, inputs_digest(repo.path(), &[]).unwrap());
1969 assert!(
1970 r.outcome.changed_files == ["solved"],
1971 "{:?}",
1972 r.outcome.changed_files
1973 );
1974
1975 let at = std::fs::read_to_string(&where_).unwrap();
1977 let at = Path::new(at.trim());
1978 assert_eq!(at.file_name().unwrap(), "repo");
1979 let parent = at
1980 .parent()
1981 .unwrap()
1982 .file_name()
1983 .unwrap()
1984 .to_string_lossy()
1985 .into_owned();
1986 assert!(
1987 parent.starts_with("mj-bench-") && parent.len() == "mj-bench-".len() + 12,
1988 "{parent}"
1989 );
1990 assert!(
1991 parent["mj-bench-".len()..]
1992 .chars()
1993 .all(|c| c.is_ascii_hexdigit()),
1994 "{parent}"
1995 );
1996 for word in [id, "baseline", "pilot", "economics"] {
1997 assert!(
1998 !at.to_string_lossy().contains(word),
1999 "{word} in {}",
2000 at.display()
2001 );
2002 }
2003 let beside = std::fs::read_to_string(&listing).unwrap();
2004 let beside: BTreeSet<&str> = beside.lines().collect();
2005 assert_eq!(
2006 beside,
2007 BTreeSet::from([".", "..", "mcp.json", "repo"]),
2008 "nothing else beside it"
2009 );
2010 assert!(
2011 !at.exists() && !at.parent().unwrap().exists(),
2012 "the workspace was removed"
2013 );
2014
2015 let transcript = work
2017 .path()
2018 .join("pilot/distinctive-task--baseline--r1/session-1.jsonl");
2019 assert!(std::fs::read_to_string(transcript)
2020 .unwrap()
2021 .contains("\"result\""));
2022 }
2023
2024 #[test]
2025 fn a_session_that_failed_or_timed_out_is_not_a_completed_run() {
2026 let (repo, work, probe) = (
2027 tempfile::tempdir().unwrap(),
2028 tempfile::tempdir().unwrap(),
2029 tempfile::tempdir().unwrap(),
2030 );
2031 repository(repo.path(), "t");
2032 let decl = declarations(
2033 suite("live", &["t"], None),
2034 vec![task("t", "test -f solved")],
2035 );
2036
2037 let failed =
2039 r#"{"type":"result","subtype":"error_max_budget_usd","is_error":true,"num_turns":3}"#;
2040 let mut opts = options(repo.path(), work.path());
2041 opts.session_timeout = Duration::from_secs(60);
2042 opts.harness = harness(probe.path(), "echo yes > solved", failed);
2043 run(&opts, &decl, &|_| {}).unwrap();
2044 let r = record(repo.path(), "t--baseline--r1");
2045 assert!(!r.outcome.completed);
2046 let gate = &r.outcome.checks[0];
2047 assert_eq!(
2048 (gate.id.as_str(), gate.passed),
2049 ("sessions_completed", false)
2050 );
2051 assert_eq!(gate.detail, "session 1 ended error_max_budget_usd (error)");
2052 assert!(
2053 r.outcome.checks[1..].iter().all(|c| c.passed),
2054 "only the session gate failed"
2055 );
2056
2057 let pid = probe.path().join("pid");
2060 let body = format!(
2061 "echo yes > solved\nsleep 60 &\necho $! > '{}'\nsleep 60",
2062 pid.display()
2063 );
2064 opts.harness = harness(probe.path(), &body, SUCCESS);
2065 opts.session_timeout = Duration::from_secs(1);
2066 let decl = declarations(
2067 serde_json::from_value(json!({
2068 "schema": "economics-suite/v1", "id": "pilot", "version": 1, "kind": "live",
2069 "title": "Pilot", "model": "claude-test", "control": "baseline",
2070 "repetitions": 2, "tasks": ["t"], "freshness_inputs": []
2071 }))
2072 .unwrap(),
2073 vec![task("t", "test -f solved")],
2074 );
2075 opts.repetitions = vec![2];
2076 let started = Instant::now();
2077 let (written, failed) = run(&opts, &decl, &|_| {}).unwrap();
2078 assert!(
2079 started.elapsed() < Duration::from_secs(30),
2080 "the timeout ended the session"
2081 );
2082 assert!(failed.is_empty() && written.len() == 1, "{failed:?}");
2083 let r = record(repo.path(), "t--baseline--r2");
2084 assert_eq!(
2085 (r.sessions[0].ended.as_str(), r.sessions[0].is_error),
2086 ("timeout", true)
2087 );
2088 assert!(!r.outcome.completed);
2089 assert_eq!(
2090 r.outcome.checks[0].detail,
2091 "session 1 ended timeout (error)"
2092 );
2093 let pid = std::fs::read_to_string(&pid).unwrap();
2094 assert!(
2095 ended(pid.trim()),
2096 "a process the session started outlived its timeout"
2097 );
2098 }
2099
2100 #[test]
2101 fn a_run_whose_checkout_changed_in_flight_is_not_recorded() {
2102 let (repo, work, probe) = (
2103 tempfile::tempdir().unwrap(),
2104 tempfile::tempdir().unwrap(),
2105 tempfile::tempdir().unwrap(),
2106 );
2107 repository(repo.path(), "t");
2108 let decl = declarations(suite("live", &["t"], None), vec![task("t", "true")]);
2109 let mut opts = options(repo.path(), work.path());
2110 opts.session_timeout = Duration::from_secs(60);
2111 let edit = format!(
2113 "echo 'x = 2' > '{}'",
2114 repo.path().join("fx/app.py").display()
2115 );
2116 opts.harness = harness(probe.path(), &edit, SUCCESS);
2117 let (written, failed) = run(&opts, &decl, &|_| {}).unwrap();
2118 assert!(written.is_empty());
2119 assert_eq!(failed.len(), 1);
2120 assert!(
2121 failed[0].contains("changed while the run was in flight"),
2122 "{}",
2123 failed[0]
2124 );
2125 assert!(!record_path(repo.path(), "pilot", "t--baseline--r1").exists());
2126 }
2127
2128 #[test]
2129 fn verify_is_bounded_and_ends_everything_it_started() {
2130 let ws = tempfile::tempdir().unwrap();
2131 let started = Instant::now();
2132 let (passed, detail) = verify(
2133 ws.path(),
2134 "sleep 60 & echo $! > pid; sleep 60",
2135 Duration::from_secs(1),
2136 );
2137 assert!(started.elapsed() < Duration::from_secs(20));
2138 assert!(!passed);
2139 assert_eq!(detail, "timed out after 1s");
2140 let pid = std::fs::read_to_string(ws.path().join("pid")).unwrap();
2141 assert!(
2142 ended(pid.trim()),
2143 "the background process survived the timeout"
2144 );
2145
2146 let (passed, detail) = verify(
2147 ws.path(),
2148 "echo first >&2; echo last >&2; exit 3",
2149 VERIFY_LIMIT,
2150 );
2151 assert!(!passed);
2152 assert_eq!(detail, "exit 3: last");
2153 assert_eq!(
2154 verify(ws.path(), "true", VERIFY_LIMIT),
2155 (true, "exit 0: ".to_string())
2156 );
2157 }
2158
2159 #[test]
2160 fn the_session_gate_names_every_session_that_did_not_succeed() {
2161 let s = |index: u32, ended: &str, is_error: bool| EconomicsSession {
2162 index,
2163 prompt_sha256: String::new(),
2164 duration_ms: 0,
2165 turns: None,
2166 ended: ended.into(),
2167 is_error,
2168 requests: vec![],
2169 models: vec![],
2170 reported_cost_microusd: None,
2171 tools: BTreeMap::new(),
2172 orientation: EconomicsOrientation::default(),
2173 };
2174 let ok = sessions_check(&[s(1, "success", false), s(2, "success", false)]);
2175 assert!(ok.passed);
2176 assert_eq!(
2177 ok.detail,
2178 "all 2 session(s) ended with the harness's success result"
2179 );
2180 let bad = sessions_check(&[
2181 s(1, "success", false),
2182 s(2, "no_result", true),
2183 s(3, "success", true),
2184 ]);
2185 assert!(!bad.passed);
2186 assert_eq!(
2187 bad.detail,
2188 "session 2 ended no_result (error); session 3 ended success (error)"
2189 );
2190 let none = sessions_check(&[]);
2191 assert!(!none.passed, "a run of no session completed nothing");
2192 }
2193
2194 #[test]
2195 fn the_benchmark_policy_turns_the_server_off_and_keeps_the_teams_files() {
2196 let skeleton = std::fs::read_to_string(concat!(
2197 env!("CARGO_MANIFEST_DIR"),
2198 "/../../share/skeleton/policy.yaml"
2199 ))
2200 .unwrap();
2201 let before: Policy = yaml::parse_into(&skeleton).unwrap();
2202 assert!(
2203 before.session.ensure_server_on_start,
2204 "the skeleton converges on a server"
2205 );
2206
2207 let text = benchmark_policy(&skeleton, &["CLAUDE.md"]).unwrap();
2208 let after: Policy = yaml::parse_into(&text).unwrap();
2209 assert!(!after.session.ensure_server_on_start);
2210 assert_eq!(
2211 after.session.freshness, before.session.freshness,
2212 "the rest of the block kept"
2213 );
2214 let modes: Vec<(&str, ProjectionMode)> = after
2215 .projections
2216 .iter()
2217 .map(|p| (p.target.as_str(), p.mode))
2218 .collect();
2219 assert_eq!(
2220 modes,
2221 [
2222 ("AGENTS.md", ProjectionMode::File),
2223 ("CLAUDE.md", ProjectionMode::Region),
2224 ("GEMINI.md", ProjectionMode::File)
2225 ]
2226 );
2227 assert_eq!(
2228 text.matches("ensure_server_on_start").count(),
2229 1,
2230 "replaced, not duplicated"
2231 );
2232 let gone: Vec<&str> = skeleton
2234 .lines()
2235 .filter(|l| !text.lines().any(|t| t == *l))
2236 .collect();
2237 assert_eq!(gone.len(), 1, "{gone:?}");
2238 assert!(
2239 gone[0]
2240 .trim_start()
2241 .starts_with("ensure_server_on_start: true"),
2242 "{gone:?}"
2243 );
2244 assert_eq!(text.lines().count(), skeleton.lines().count() + 1);
2245
2246 let absent = "version: 1\nsession:\n briefing_on_start: true\nprojections: []\n";
2248 let text = benchmark_policy(absent, &[]).unwrap();
2249 assert!(
2250 text.contains("session:\n ensure_server_on_start: false\n briefing_on_start: true\n"),
2251 "{text}"
2252 );
2253 let text = benchmark_policy("version: 1\nprojections: []\n", &[]).unwrap();
2254 assert!(
2255 !yaml::parse_into::<Policy>(&text)
2256 .unwrap()
2257 .session
2258 .ensure_server_on_start
2259 );
2260
2261 let nested =
2263 "session:\n briefing_on_start: true\n other:\n ensure_server_on_start: true\n";
2264 let text = benchmark_policy(nested, &[]).unwrap();
2265 assert!(
2266 text.contains("\n ensure_server_on_start: true\n"),
2267 "the nested key is left alone"
2268 );
2269
2270 let err = benchmark_policy("version: 1\nprojections: []\n", &["CLAUDE.md"]).unwrap_err();
2272 assert!(err.contains("CLAUDE.md"), "{err}");
2273 assert!(benchmark_policy("session: { ensure_server_on_start: true }\n", &[]).is_err());
2274 }
2275
2276 #[test]
2277 fn a_reference_must_fail_before_and_pass_after() {
2278 let (repo, work) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
2279 write(&repo.path().join("fx/app.py"), "x = 1\n");
2280 write(&repo.path().join("fx/tests/test_app.py"), "assert True\n");
2281 write(
2282 &repo.path().join("hidden/test_hidden.py"),
2283 "assert solved\n",
2284 );
2285 write(&repo.path().join("ref.patch"), CREATE_SOLVED);
2286 let solves = task("solves", "test -f tests/test_hidden.py && test -f solved");
2288 let trivial = task("trivial", "true");
2289 let mut unapplied = task("unapplied", "test -f solved");
2290 unapplied.reference = "missing.patch".into();
2291 let decl = declarations(suite("live", &[], None), vec![solves, trivial, unapplied]);
2292
2293 let bad = check_references(repo.path(), &decl, work.path()).unwrap_err();
2294 assert_eq!(bad.len(), 2, "{bad:?}");
2295 assert_eq!(
2296 bad[0],
2297 "trivial: the hidden tests already pass on the starting state"
2298 );
2299 assert!(
2300 bad[1].starts_with("unapplied: the reference does not apply"),
2301 "{}",
2302 bad[1]
2303 );
2304
2305 let only = declarations(
2306 suite("live", &[], None),
2307 vec![task("solves", "test -f solved")],
2308 );
2309 let ok = check_references(repo.path(), &only, work.path()).unwrap();
2310 assert_eq!(
2311 ok,
2312 ["solves: fails on the starting state, passes on the reference"]
2313 );
2314 assert!(
2315 files(&work.path().join("references")).is_empty(),
2316 "no hidden test left behind"
2317 );
2318 }
2319}