Skip to main content

majordomus_cli/economics/
runner.rs

1//! The live runner: a task, run by a real coding harness, once without Majordomus and once
2//! with it, from the same fixture, with the same model, judged by the same hidden tests.
3//!
4//! For each run the runner:
5//!
6//! 1. takes the provenance the record will carry — the repository's revision, the digest of
7//!    the suite's freshness inputs, the harness's version — before anything else happens;
8//! 2. copies the task's fixture into a fresh workspace, applies the task's setup patch, and
9//!    commits it — the control's starting state. The workspace is `repo/` inside a new
10//!    directory of the system temporary directory named by a digest: nothing in its path
11//!    says which task, which arm or which benchmark it belongs to, and nothing beside it
12//!    holds a hidden test, a transcript or another run's workspace;
13//! 3. for the treatment only, installs Majordomus the way a user does (`init`, `update`,
14//!    `capture install --provider claude-code`) and commits that too;
15//! 4. runs every session of the task in the harness (Claude Code, headless, `stream-json`),
16//!    with the user's own settings, MCP servers and skills shut out for both arms alike, each
17//!    session under a wall-clock limit;
18//! 5. runs the task's verify command, copies the hidden acceptance tests in, runs it again,
19//!    and checks no test file the fixture started with was deleted;
20//! 6. refuses the run if the freshness inputs or the harness changed while it was in flight,
21//!    deletes the workspace, and writes one [`EconomicsRun`] with the provider's usage as
22//!    reported, and nothing a session said. A record, once written, is never written over.
23//!
24//! The harness is a parameter: a test passes a replaying stand-in and exercises the whole
25//! pipeline — workspace, sessions, gates, record — without a provider, a credential or a cent.
26//! The transcripts stay in the work directory, `<work dir>/<suite>/<run id>/`, outside the
27//! repository and outside every workspace.
28//!
29//! ```
30//! use majordomus_cli::economics::runner::run_id;
31//! assert_eq!(run_id("vat-rounding", "baseline", 2), "vat-rounding--baseline--r2");
32//! ```
33
34use 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
48/// How long a task's verify command may run. A test suite that hangs must end its run as a
49/// failed check, not hold the whole suite.
50const VERIFY_LIMIT: Duration = Duration::from_secs(600);
51
52/// Why a recorded run is never replaced, said wherever replacing one is asked for.
53const 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
56/// The id of a run: task, variant, repetition. One file per id; a recorded run is never
57/// overwritten.
58///
59/// ```
60/// use majordomus_cli::economics::runner::run_id;
61/// let id = run_id("storage-v2", "majordomus", 1);
62/// assert_eq!(id, "storage-v2--majordomus--r1");
63/// // the two arms of one task and repetition differ in the variant alone
64/// assert_ne!(run_id("storage-v2", "baseline", 1), id);
65/// ```
66pub fn run_id(task: &str, variant: &str, repetition: u32) -> String {
67    format!("{task}--{variant}--r{repetition}")
68}
69
70/// Everything a live suite run is told. The work directory must lie outside `root`, and
71/// [`run`] refuses the options otherwise, because the transcripts it collects there quote
72/// prompts and code that never belong in the repository's history.
73///
74/// ```
75/// # use majordomus_cli::economics::{model::*, Declarations};
76/// # let methodology: EconomicsMethodology = serde_json::from_value(serde_json::json!({
77/// #     "schema": "economics-methodology/v1", "version": 1, "title": "t", "question": "q",
78/// #     "unit": "tokens", "primary_metric": "m", "classes": [], "variants": [], "success": [],
79/// #     "pairing": { "key": [], "comparable": [], "valid": "v" },
80/// #     "statistics": { "per_pair": "p", "location": "median", "interval": "i",
81/// #         "confidence_bp": 9500, "resamples": 1, "seed": 1, "min_pairs_for_interval": 1 },
82/// #     "publication": { "min_valid_pairs": 1, "min_categories": 1,
83/// #         "min_pairs_per_category": 1, "min_repetitions": 1, "min_valid_pair_rate_bp": 1,
84/// #         "max_interval_width_bp": 1, "require_current": true },
85/// #     "outliers": { "rule": "r" } })).unwrap();
86/// # let decl =
87/// #     Declarations { methodology, suites: Default::default(), tasks: Default::default() };
88/// use std::time::Duration;
89/// use majordomus_cli::economics::runner::{run, RunOptions};
90/// let (repo, work) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
91/// let mut opts = RunOptions {
92///     root: repo.path().into(), suite: "nightly".into(), tasks: vec![], repetitions: vec![1],
93///     work_dir: work.path().into(), harness: "claude".into(), majordomus: "majordomus".into(),
94///     parallel: 2, force: false, dry_run: true, session_timeout: Duration::from_secs(900),
95/// };
96/// // the suite is looked up in the declarations, never invented from the options
97/// assert_eq!(run(&opts, &decl, &|_| {}).unwrap_err(), "no suite \"nightly\"");
98/// // and a recorded run is never written over, whatever the options say
99/// opts.force = true;
100/// assert!(run(&opts, &decl, &|_| {}).unwrap_err().contains("never overwritten"));
101/// ```
102#[derive(Debug, Clone)]
103pub struct RunOptions {
104    /// The repository whose declarations and share are used.
105    pub root: PathBuf,
106    /// The suite.
107    pub suite: String,
108    /// Only these tasks; all of the suite's when empty.
109    pub tasks: Vec<String>,
110    /// Only these repetitions; all of the suite's when empty.
111    pub repetitions: Vec<u32>,
112    /// Where transcripts go. Never inside the repository. The workspaces are not here: each
113    /// is a fresh directory of the system temporary directory, removed when its run ends.
114    pub work_dir: PathBuf,
115    /// The harness executable (`claude`, or a stand-in).
116    pub harness: PathBuf,
117    /// The `majordomus` executable the treatment installs from.
118    pub majordomus: PathBuf,
119    /// Runs in flight at once.
120    pub parallel: usize,
121    /// Asks to record over an existing run, and is always refused by [`run`]: a recorded
122    /// run is evidence. A run that should not count is excluded in the methodology, with its
123    /// reason, and another repetition is recorded instead.
124    pub force: bool,
125    /// Prepare the workspaces and print the commands, run nothing.
126    pub dry_run: bool,
127    /// Per-session wall-clock limit.
128    pub session_timeout: Duration,
129}
130
131/// One unit of work.
132#[derive(Debug, Clone)]
133struct Job {
134    task: EconomicsTask,
135    variant: String,
136    repetition: u32,
137}
138
139/// A digest of a fixture tree and of further declaration files, each named by its path
140/// relative to the repository, so that the same fixture digests the same in every checkout.
141fn 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
197/// The variables that tie a process to the session or the repository it was started from.
198/// A child that inherited one would report into the operator's own session (`CLAUDECODE`,
199/// the session id, the IDE's port), resolve the operator's project (`CLAUDE_PROJECT_DIR`),
200/// find a build of the operator's (`CARGO_TARGET_DIR`), or run git against the operator's
201/// repository instead of its workspace (`GIT_DIR` and its kin). Credentials and provider
202/// routing (`ANTHROPIC_*`, `CLAUDE_CODE_USE_BEDROCK`, ...) are not among them: both arms
203/// need them, and alike.
204const 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
221/// Prefixes of the same: the operator's Majordomus state, read by the shell tool and the
222/// executable alike.
223const 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
229/// The environment a harness and everything it spawns runs in: the caller's, minus every
230/// variable that ties the child to the caller's session or repository. `bin`, when given,
231/// is the `majordomus` executable whose directory goes first on `PATH`, which is how the
232/// treatment's hooks find the tool they were installed from; the control is given none.
233fn 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    // The harness updates itself in the background unless told not to, and a version that
248    // changes between two runs of one suite makes them incomparable.
249    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/// The process groups the runner started and has not reaped yet, killed with the runner
288/// when it dies of `SIGINT`, `SIGTERM` or `SIGHUP`. Every harness session and every
289/// verification runs as the leader of a group of its own, so that a timeout can end it and
290/// everything it started; the price is that a Ctrl-C at the terminal no longer reaches them,
291/// and this is what pays it back. The handler does only what is safe inside a signal
292/// handler: `killpg` each recorded group, restore the default disposition, and raise the
293/// signal again so that the exit status still says which signal it was.
294#[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    /// A recorded group; dropping it forgets the group.
304    pub struct Registered(Option<usize>);
305
306    /// Record `pgid` until the returned value is dropped. More groups at once than there
307    /// are slots is not an error: the surplus is simply not killed on a signal.
308    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                // SAFETY: installing a handler that only calls async-signal-safe functions
313                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                // SAFETY: killpg is async-signal-safe; the group is one this process started
343                unsafe { libc::killpg(pgid, libc::SIGKILL) };
344            }
345        }
346        // SAFETY: restoring the default disposition and re-raising are async-signal-safe
347        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
363/// Start `cmd` as the leader of a process group of its own, recorded for [`groups`].
364fn 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
375/// Wait for `child`, started by [`spawn_group`], at most `limit`. On expiry its whole
376/// process group — the child and everything it started — is killed and the child reaped,
377/// and `None` says it never finished.
378fn 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            // SAFETY: the child leads its own group (`spawn_group`) and is not reaped yet,
387            // so the group id is still its own and names no other process's group.
388            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
399/// The directory one run works in: a fresh directory of the system temporary directory,
400/// named `mj-bench-` and twelve hex digits of a digest of the run id, the process and the
401/// time, so that its path says nothing about the task, the arm or the benchmark. It holds
402/// the repository the agent works in (`repo/`) and the harness's empty MCP configuration,
403/// and nothing else; it is removed with everything in it when the value is dropped.
404struct 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        // `create_dir`, not `create_dir_all`: a directory that already exists is not fresh
417        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    /// Remove it now, and say so if that failed; the drop that follows is then a no-op.
430    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
441/// The treatment's policy, changed in the two ways the benchmark needs, or why it could not
442/// be:
443///
444/// - every projection whose target the fixture already has (`region`) is switched to region
445///   mode, so that `update` adds the bootstrap to the team's own file as a generated region
446///   instead of refusing to replace it;
447/// - `session.ensure_server_on_start` is set to `false`. The methodology leaves the MCP
448///   server out of the treatment, and with it on, what the start hook tells the agent would
449///   depend on the operator's machine: it starts a server when a build of the executable is
450///   at hand and tells the agent to build one when it is not.
451///
452/// The result is read back with the crate's own policy reader, and a text in which either
453/// change did not take is refused: a treatment that silently kept the skeleton's defaults
454/// would be measured as a different treatment.
455fn 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            // the block runs to the next key at the top level
487            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
533/// The identity every workspace's commits carry. An agent reads `git log` as readily as the
534/// tree, so the history says no more than the path does: nothing in it names a benchmark, a
535/// task or an arm.
536const WORKSPACE_AUTHOR: (&str, &str) = ("Developer", "developer@example.invalid");
537/// The message of a workspace's first commit, which holds the fixture with the task's setup
538/// patch folded in, so that the setup is not shown to the agent as a change of its own.
539const WORKSPACE_FIRST_COMMIT: &str = "Initial commit";
540
541/// Build a task's starting state in `ws`: fixture, setup patch, a commit; then, for the
542/// treatment, Majordomus installed as a user installs it, and a second commit.
543fn 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        // what the installation left is what the sessions will read
578        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
588/// The harness invocation, prompt excepted: what the configuration digest covers.
589fn 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
620/// The digest of a harness invocation, the path of the run's own empty MCP configuration
621/// masked: two runs invoked alike digest alike, whichever workspace each ran in. The arm is
622/// not part of it — the treatment differs in what its repository holds, never in how the
623/// harness is called — so a control and a treatment of one pair must digest the same.
624fn 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
656/// Run one session and read its transcript. A session the timeout ended is recorded as
657/// having ended `timeout`, whatever its transcript got as far as saying.
658fn 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
707/// The gate a run's sessions pass: every session ended with the harness's own success
708/// result — a `result` event of subtype `success`, not flagged as an error, with usage
709/// reported, and not ended by the timeout. A run whose last session was cut off can still
710/// leave a tree that passes the tests; it is still not a completed run of the task.
711fn 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
741/// Run a task's verify command in `ws` for at most `limit`. On expiry the command's whole
742/// process group is killed and the check fails as having timed out. Nothing Majordomus
743/// installed is on its `PATH`: both arms are verified alike.
744fn 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    // Drained on a thread of its own, so that a suite writing more than a pipe holds is not
757    // blocked on it while it is being waited for.
758    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
786/// Files changed since the starting state `base`, committed or not. What the treatment's
787/// own records write under `.ai/local/` is the tool's state, not the task's work.
788fn 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
800/// Run one job end to end and return its record. Never panics on a failing session: a
801/// session that errs is recorded as having erred, and the gates decide the outcome. The
802/// workspace is gone when this returns, whatever it returns.
803fn 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    // The provenance, before anything is prepared: what the run is recorded against is what
814    // it started from, and the end of the run is checked against it.
815    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    // what the sessions changed, taken before the hidden tests are copied in
885    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    // A run is recorded against what it started from only if that is still what there is.
931    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
995/// Where a run's record is written: one JSON file per run id under the suite's directory of
996/// the evidence tree. [`run`] skips a job whose record already exists here, which is what
997/// makes an interrupted suite resumable, and never writes over one.
998///
999/// ```
1000/// use std::path::Path;
1001/// use majordomus_cli::economics::{runner::{record_path, run_id}, DIR};
1002/// let id = run_id("vat-rounding", "baseline", 2);
1003/// let path = record_path(Path::new("/repo"), "pilot", &id);
1004/// let expected = Path::new("/repo").join(DIR).join("runs/pilot/vat-rounding--baseline--r2.json");
1005/// assert_eq!(path, expected);
1006/// ```
1007pub 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
1014/// Write a record that does not exist yet. The file is created exclusively (`O_EXCL`), so a
1015/// record that appeared in the meantime is refused rather than replaced; a write that fails
1016/// half way removes the file it created, which held nothing but this run's partial text.
1017fn 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
1043/// `dir` made absolute, created, and canonical; refused when it lies inside `root`, taken
1044/// canonical too, so that neither a symbolic link nor a relative spelling can put it there.
1045/// A directory refused is taken back: whatever this created for the check is removed again,
1046/// deepest first, as long as it is still empty.
1047fn 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
1080/// Run a live suite: every job not yet recorded, `parallel` at a time, control and
1081/// treatment of one task and repetition side by side so that both meet the provider under
1082/// the same conditions. Returns the records written and the jobs that failed to produce
1083/// one. Refuses, before any workspace exists, `force` (a recorded run is never written
1084/// over), an unknown suite, a suite that is not `live`, a task the suite names but nobody
1085/// declared, and a work directory — or a system temporary directory — inside the
1086/// repository.
1087///
1088/// ```
1089/// # use majordomus_cli::economics::{model::*, Declarations};
1090/// # let methodology: EconomicsMethodology = serde_json::from_value(serde_json::json!({
1091/// #     "schema": "economics-methodology/v1", "version": 1, "title": "t", "question": "q",
1092/// #     "unit": "tokens", "primary_metric": "m", "classes": [], "variants": [], "success": [],
1093/// #     "pairing": { "key": [], "comparable": [], "valid": "v" },
1094/// #     "statistics": { "per_pair": "p", "location": "median", "interval": "i",
1095/// #         "confidence_bp": 9500, "resamples": 1, "seed": 1, "min_pairs_for_interval": 1 },
1096/// #     "publication": { "min_valid_pairs": 1, "min_categories": 1,
1097/// #         "min_pairs_per_category": 1, "min_repetitions": 1, "min_valid_pair_rate_bp": 1,
1098/// #         "max_interval_width_bp": 1, "require_current": true },
1099/// #     "outliers": { "rule": "r" } })).unwrap();
1100/// # let pilot: EconomicsSuite = serde_json::from_value(serde_json::json!({
1101/// #     "schema": "economics-suite/v1", "id": "pilot", "version": 1, "kind": "live",
1102/// #     "title": "Pilot", "model": "claude-test", "control": "baseline",
1103/// #     "treatment": "majordomus", "tasks": [], "freshness_inputs": [] })).unwrap();
1104/// # let decl = Declarations {
1105/// #     methodology, suites: [("pilot".to_string(), pilot)].into(), tasks: Default::default() };
1106/// use std::{sync::Mutex, time::Duration};
1107/// use majordomus_cli::economics::runner::{run, RunOptions};
1108/// let (repo, work) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1109/// let mut opts = RunOptions {
1110///     root: repo.path().into(), suite: "pilot".into(), tasks: vec![], repetitions: vec![],
1111///     work_dir: work.path().into(), harness: "claude".into(), majordomus: "majordomus".into(),
1112///     parallel: 1, force: false, dry_run: false, session_timeout: Duration::from_secs(600),
1113/// };
1114/// // `pilot` is a live suite that names no task yet: nothing runs, nothing is written
1115/// let log = Mutex::new(Vec::new());
1116/// let (written, failed) = run(&opts, &decl, &|line| log.lock().unwrap().push(line)).unwrap();
1117/// assert!(written.is_empty() && failed.is_empty());
1118/// assert_eq!(log.into_inner().unwrap(), ["pilot: 0 run(s) to do, 1 at a time"]);
1119/// // transcripts never enter the repository, and the refusal leaves nothing behind
1120/// opts.work_dir = repo.path().join("economics-work");
1121/// assert!(run(&opts, &decl, &|_| {}).unwrap_err().contains("outside the repository"));
1122/// assert!(!opts.work_dir.exists());
1123/// ```
1124pub 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    // the workspaces go to the system temporary directory, which must not be the repository's
1145    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    // in the order of the runs, not of whichever thread finished first; a record's path is
1227    // its run's id, so the ids are what is ordered
1228    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
1237/// The reference solution of every task applied to its fixture: the hidden tests must fail
1238/// on the starting state and pass on the reference, or a failing run would say nothing about
1239/// the agent. Returns one line per task, and `Err` listing every task that breaks the rule.
1240/// Each task is prepared under `<work_dir>/references/<task>` with the control's starting
1241/// state and removed once checked; no harness and no provider is involved, so the check
1242/// costs nothing to repeat.
1243///
1244/// ```
1245/// # use majordomus_cli::economics::{model::*, Declarations};
1246/// # let methodology: EconomicsMethodology = serde_json::from_value(serde_json::json!({
1247/// #     "schema": "economics-methodology/v1", "version": 1, "title": "t", "question": "q",
1248/// #     "unit": "tokens", "primary_metric": "m", "classes": [], "variants": [], "success": [],
1249/// #     "pairing": { "key": [], "comparable": [], "valid": "v" },
1250/// #     "statistics": { "per_pair": "p", "location": "median", "interval": "i",
1251/// #         "confidence_bp": 9500, "resamples": 1, "seed": 1, "min_pairs_for_interval": 1 },
1252/// #     "publication": { "min_valid_pairs": 1, "min_categories": 1,
1253/// #         "min_pairs_per_category": 1, "min_repetitions": 1, "min_valid_pair_rate_bp": 1,
1254/// #         "max_interval_width_bp": 1, "require_current": true },
1255/// #     "outliers": { "rule": "r" } })).unwrap();
1256/// # let solve: EconomicsTask = serde_json::from_value(serde_json::json!({
1257/// #     "schema": "economics-task/v1", "id": "solve", "title": "Solve", "category": "c",
1258/// #     "complexity": "small", "source": "s", "fixture": "fx", "acceptance": "hidden",
1259/// #     "reference": "ref.patch", "verify": "test -f solved", "scope": [], "sessions": [] }))
1260/// #     .unwrap();
1261/// # let decl = Declarations {
1262/// #     methodology, suites: Default::default(), tasks: [("solve".to_string(), solve)].into() };
1263/// use majordomus_cli::economics::runner::check_references;
1264/// let (repo, work) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1265/// // task `solve` starts from `fx/`, verifies with `test -f solved`, and its reference
1266/// // solution `ref.patch` creates that file
1267/// std::fs::create_dir_all(repo.path().join("fx")).unwrap();
1268/// std::fs::write(repo.path().join("fx/app.py"), "x = 1\n").unwrap();
1269/// let patch = "--- /dev/null\n+++ b/solved\n@@ -0,0 +1 @@\n+yes\n";
1270/// std::fs::write(repo.path().join("ref.patch"), patch).unwrap();
1271/// let lines = check_references(repo.path(), &decl, work.path()).unwrap();
1272/// assert_eq!(lines, ["solve: fails on the starting state, passes on the reference"]);
1273/// // and the workspace, hidden tests and all, is gone
1274/// assert!(!work.path().join("references/solve").exists());
1275/// ```
1276pub 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    /// A repository with a task `id` whose fixture has one test file, whose hidden test is
1419    /// `hidden/test_hidden.py`, and whose prompt is `prompt-1.md`, committed: a run records
1420    /// against a HEAD.
1421    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    /// A stand-in harness: answers `--version`, runs `body` in the workspace, and prints a
1447    /// transcript that ends with `result`.
1448    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    /// Whether the process `pid` is gone within a few seconds. A zombie counts as gone: it
1468    /// has been killed, and only its reaping is left to whoever inherited it.
1469    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        // the two arms of a pair are invoked alike from different workspaces
1588        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        // credentials and provider routing: both arms need them, and alike
1624        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        // the probes go before anything is asserted, so a failure leaves no residue behind
1669        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        // a link outside that leads in
1732        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        // and a repository named through a link, with the work directory spelled directly
1741        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        // a directory that already existed is left where it was
1746        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        // the history names no benchmark, task or arm: one commit, by a plain identity
1879        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        // the workspace named nothing, sat alone, and is gone
1976        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        // the transcript is in the work directory, not in the workspace
2016        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        // the tree passes every test, but the harness reported an error
2038        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        // cut off by the timeout after it solved the task and said so: still not completed,
2058        // and whatever it started is killed with it
2059        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        // the session edits a tracked file of the operator's checkout
2112        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        // one line replaced, one added (`mode: region`), nothing else touched
2233        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        // a policy without the key gets it, one without the block gets both
2247        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        // a nested key of the same name is not the setting
2262        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        // a change that cannot be made is refused, never run as a different treatment
2271        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        // the hidden test is copied in before either verification
2287        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}