Skip to main content

scx_forge_agent/
main.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: GPL-2.0-only
3//! scx-forge-agent: an LLM-driven optimizer for sched_ext schedulers.
4//!
5//! Hybrid loop: deterministic code owns build/validate/keep-revert/stop; the LLM
6//! is called only to propose one coherent policy experiment per round (and to fix
7//! build errors).
8//! The reward function is the built-in validation harness (`validate.rs`). The
9//! target scheduler is whatever `[scheduler].package` in the spec names (any
10//! `scheds/rust/<name>` crate); the agent modifies that crate in place. See
11//! `tools/scx_forge_agent/README.md`.
12
13mod agent_cli;
14mod api;
15mod color;
16mod config;
17mod git;
18mod http;
19mod interrupt;
20mod model_timeout;
21mod progress;
22mod report;
23mod spec;
24mod sudo;
25mod tools;
26mod usage;
27mod validate;
28
29use std::collections::BTreeSet;
30use std::ffi::OsString;
31use std::io::Write;
32use std::os::unix::process::CommandExt;
33use std::path::{Path, PathBuf};
34use std::process::Command;
35use std::sync::{
36    Arc,
37    atomic::{AtomicBool, Ordering},
38};
39use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
40
41use anyhow::{Context, Result};
42use clap::Parser;
43
44use progress::ProgressSpinner;
45use report::{Report, RoundRecord};
46
47/// The resource markdown files are embedded at compile time so prompt edits stay
48/// close to the domain text instead of the Rust control flow.
49const SKILL_MD: &str = include_str!("../resources/SKILL.md");
50const OPTIMIZER_MD: &str = include_str!("../resources/OPTIMIZER.md");
51const KNOB_MD: &str = include_str!("../resources/KNOB.md");
52
53#[derive(Debug)]
54struct OptimizeOutcome {
55    report: Report,
56    interrupted: bool,
57}
58
59pub(crate) fn cargo_program() -> OsString {
60    std::env::var_os("CARGO")
61        .filter(|v| !v.is_empty())
62        .unwrap_or_else(|| OsString::from("cargo"))
63}
64
65#[derive(Parser, Debug)]
66#[command(
67    name = "scx-forge-agent",
68    version,
69    about = "LLM-driven sched_ext scheduler optimizer"
70)]
71struct OptimizeArgs {
72    /// Path to the scx git repo (defaults to the git toplevel of the cwd).
73    #[arg(long)]
74    source: Option<PathBuf>,
75
76    /// Override the scheduler crate directory, relative to --source. Defaults to
77    /// `scheds/rust/<package>`, derived from the spec's [scheduler].package.
78    #[arg(long)]
79    crate_dir: Option<String>,
80
81    /// Validation spec TOML (defaults to tools/scx_forge_agent/spec.toml). The
82    /// scheduler to optimize (and the cargo profile) come from its [scheduler]
83    /// section, so the same spec drives both the build gate and the harness.
84    #[arg(long, visible_alias = "spec-toml", value_name = "SPEC_TOML")]
85    spec: Option<PathBuf>,
86
87    /// Save this run's compact attempt summary to PATH when the run completes.
88    #[arg(long, value_name = "PATH")]
89    save: Option<PathBuf>,
90
91    /// Resume from a previously saved attempt-summary state file.
92    #[arg(long, value_name = "PATH")]
93    resume: Option<PathBuf>,
94
95    /// Print the assembled prompt and planned loop without calling the model or
96    /// loading a scheduler.
97    #[arg(long)]
98    dry_run: bool,
99
100    /// After optimization completes, rebuild and run the final scheduler in the current terminal.
101    #[arg(long)]
102    keep_running: bool,
103
104    /// Emit the final report as JSON instead of markdown.
105    #[arg(long)]
106    json: bool,
107
108    /// Dump the full per-round transcript to stderr: prompts sent to the model,
109    /// assistant replies, tool calls + results, token usage, and harness steps.
110    #[arg(long)]
111    verbose: bool,
112
113    /// Disable ANSI color in live progress output.
114    #[arg(long)]
115    no_color: bool,
116}
117
118fn resolve_repo_root(source: &Option<PathBuf>) -> Result<PathBuf> {
119    if let Some(s) = source {
120        return Ok(s.clone());
121    }
122    let out = Command::new("git")
123        .args(["rev-parse", "--show-toplevel"])
124        .output()
125        .context("git rev-parse --show-toplevel")?;
126    if !out.status.success() {
127        anyhow::bail!("not inside a git repo; pass --source");
128    }
129    Ok(PathBuf::from(
130        String::from_utf8_lossy(&out.stdout).trim().to_string(),
131    ))
132}
133
134/// Build the scheduler crate. Returns Err(combined output) on failure.
135fn cargo_build(
136    source: &Path,
137    package: &str,
138    profile: &str,
139    interrupted: &AtomicBool,
140) -> std::result::Result<(), String> {
141    let cargo = cargo_program();
142    let mut command = Command::new(&cargo);
143    command
144        .args(["build", "--profile", profile, "-p", package])
145        .current_dir(source)
146        .stdout(std::process::Stdio::piped())
147        .stderr(std::process::Stdio::piped());
148    unsafe {
149        command.pre_exec(|| {
150            libc::setsid();
151            Ok(())
152        });
153    }
154    let mut child = command
155        .spawn()
156        .map_err(|e| format!("spawn {} build: {e}", cargo.to_string_lossy()))?;
157    let pid = child.id() as i32;
158    loop {
159        if interrupt::requested(interrupted) {
160            unsafe {
161                libc::kill(-pid, libc::SIGINT);
162            }
163            let deadline = Instant::now() + Duration::from_secs(2);
164            loop {
165                match child.try_wait() {
166                    Ok(Some(_)) | Err(_) => break,
167                    Ok(None) if Instant::now() >= deadline => {
168                        unsafe {
169                            libc::kill(-pid, libc::SIGKILL);
170                        }
171                        break;
172                    }
173                    Ok(None) => std::thread::sleep(Duration::from_millis(50)),
174                }
175            }
176            let out = child
177                .wait_with_output()
178                .map_err(|e| format!("collect {} build output: {e}", cargo.to_string_lossy()))?;
179            let mut s = String::from("cargo build interrupted by Ctrl-C\n");
180            s.push_str(&String::from_utf8_lossy(&out.stderr));
181            s.push_str(&String::from_utf8_lossy(&out.stdout));
182            return Err(s);
183        }
184        match child.try_wait() {
185            Ok(Some(_)) => break,
186            Ok(None) => std::thread::sleep(Duration::from_millis(100)),
187            Err(e) => return Err(format!("poll {} build: {e}", cargo.to_string_lossy())),
188        }
189    }
190    let out = child
191        .wait_with_output()
192        .map_err(|e| format!("collect {} build output: {e}", cargo.to_string_lossy()))?;
193    if out.status.success() {
194        Ok(())
195    } else {
196        let mut s = String::from_utf8_lossy(&out.stderr).to_string();
197        s.push_str(&String::from_utf8_lossy(&out.stdout));
198        Err(s)
199    }
200}
201
202fn cargo_build_with_progress(
203    source: &Path,
204    package: &str,
205    profile: &str,
206    show_progress: bool,
207    color: color::Style,
208    interrupted: &AtomicBool,
209) -> std::result::Result<(), String> {
210    let _spinner = show_progress
211        .then(|| ProgressSpinner::stdout(format!("re-building scheduler {package}..."), color));
212    cargo_build(source, package, profile, interrupted)
213}
214
215fn home_dir() -> Option<PathBuf> {
216    std::env::var_os("HOME")
217        .filter(|home| !home.is_empty())
218        .map(PathBuf::from)
219}
220
221fn expand_glob_path(path: &Path) -> Result<PathBuf> {
222    let pattern = path.to_string_lossy();
223    if glob::Pattern::escape(&pattern) == pattern {
224        return Ok(path.to_path_buf());
225    }
226
227    let mut matches = glob::glob(&pattern)
228        .with_context(|| format!("parse [system].sudo_passwd_file pattern: {pattern}"))?
229        .collect::<std::result::Result<Vec<_>, _>>()
230        .with_context(|| format!("expand [system].sudo_passwd_file pattern: {pattern}"))?;
231    matches.sort();
232
233    match matches.len() {
234        0 => anyhow::bail!(
235            "[system].sudo_passwd_file pattern matched no files: {}",
236            path.display()
237        ),
238        1 => Ok(matches.remove(0)),
239        n => anyhow::bail!(
240            "[system].sudo_passwd_file pattern is ambiguous ({} matches): {}",
241            n,
242            path.display()
243        ),
244    }
245}
246
247fn expand_sudo_password_file(spec_path: &Path, pw: &Path) -> Result<PathBuf> {
248    expand_sudo_password_file_in(spec_path, pw, home_dir())
249}
250
251fn expand_sudo_password_file_in(
252    spec_path: &Path,
253    pw: &Path,
254    home: Option<PathBuf>,
255) -> Result<PathBuf> {
256    let raw = pw.to_string_lossy();
257    let expanded = if raw == "~" {
258        home.context("expand ~ in [system].sudo_passwd_file: HOME is not set")?
259    } else if let Some(rest) = raw.strip_prefix("~/") {
260        home.context("expand ~ in [system].sudo_passwd_file: HOME is not set")?
261            .join(rest)
262    } else {
263        let path = PathBuf::from(raw.as_ref());
264        if path.is_absolute() {
265            path
266        } else {
267            spec_path
268                .parent()
269                .unwrap_or_else(|| Path::new("."))
270                .join(path)
271        }
272    };
273    expand_glob_path(&expanded)
274}
275
276fn spec_sudo_password_file(spec_path: &Path, spec: &spec::Spec) -> Result<Option<PathBuf>> {
277    let Some(pw) = spec.system.sudo_passwd_file.as_ref() else {
278        return Ok(None);
279    };
280    if pw.as_os_str().is_empty() {
281        return Ok(None);
282    }
283    Ok(Some(expand_sudo_password_file(spec_path, pw)?))
284}
285
286fn sudo_password_file_from_spec(spec_path: &Path, spec: &spec::Spec) -> Result<Option<PathBuf>> {
287    let Some(pw) = spec_sudo_password_file(spec_path, spec)? else {
288        return Ok(None);
289    };
290    if !pw.is_file() {
291        anyhow::bail!("[system].sudo_passwd_file not found: {}", pw.display());
292    }
293    Ok(Some(pw))
294}
295
296fn agent_log(color: color::Style, msg: impl AsRef<str>) {
297    eprintln!("{} {}", color.dim("[scx-forge-agent]"), msg.as_ref());
298}
299
300fn agent_info(color: color::Style, msg: impl AsRef<str>) {
301    agent_log(color, color.cyan(msg));
302}
303
304fn agent_warn(color: color::Style, msg: impl AsRef<str>) {
305    agent_log(color, color.yellow(msg));
306}
307
308fn agent_error(color: color::Style, msg: impl AsRef<str>) {
309    agent_log(color, color.red(msg));
310}
311
312fn agent_success(color: color::Style, msg: impl AsRef<str>) {
313    agent_log(color, color.green(msg));
314}
315
316fn agent_dim(color: color::Style, msg: impl AsRef<str>) {
317    agent_log(color, color.dim(msg));
318}
319
320fn model_display_name(model: &config::ModelConfig) -> String {
321    if model.model_id.trim().is_empty() {
322        format!("{} default", model.backend.as_str())
323    } else {
324        model.model_id.clone()
325    }
326}
327
328/// Backend label for the model footer: just the backend name for the subprocess
329/// CLIs, and `openai @ <base_url>` for the built-in HTTP backend so the endpoint
330/// is visible.
331fn model_backend_label(model: &config::ModelConfig) -> String {
332    if model.backend == config::Backend::OpenAi && !model.base_url.is_empty() {
333        format!("{} @ {}", model.backend.as_str(), model.base_url)
334    } else {
335        model.backend.as_str().to_string()
336    }
337}
338
339struct ActiveModelPrinter {
340    enabled: bool,
341    color: color::Style,
342    active: Option<(String, config::Backend, String)>,
343}
344
345impl ActiveModelPrinter {
346    fn new(enabled: bool, color: color::Style) -> Self {
347        Self {
348            enabled,
349            color,
350            active: None,
351        }
352    }
353
354    fn use_model(&mut self, role: &str, model: &config::ModelConfig) {
355        let model_name = model_display_name(model);
356        let next = (role.to_string(), model.backend, model_name.clone());
357        if !self.enabled || self.active.as_ref() == Some(&next) {
358            self.active = Some(next);
359            return;
360        }
361
362        println!(
363            "{}",
364            self.color.dim(format!(
365                "[scx-forge-agent] using {role} model: {model_name} ({})",
366                model_backend_label(model)
367            ))
368        );
369        self.active = Some(next);
370    }
371}
372
373#[allow(clippy::too_many_arguments)]
374async fn run_coding_turn(
375    client: &reqwest::Client,
376    models: &config::ModelRoles,
377    coding_system: &str,
378    user: &str,
379    scheduler_cwd: &Path,
380    edit_tl: &api::ToolLoopConfig,
381    verbose: bool,
382    stream_stdout: bool,
383    stderr_color: color::Style,
384    active_model: &mut ActiveModelPrinter,
385    total_usage: &mut usage::Usage,
386    turn_timeout: Duration,
387    interrupted: Arc<AtomicBool>,
388) -> Result<std::result::Result<String, String>> {
389    active_model.use_model("coding", &models.coding);
390    if models.coding.backend.is_subprocess() {
391        let content = agent_cli::run(
392            &models.coding,
393            agent_cli::Mode::Edit,
394            stream_stdout,
395            coding_system,
396            user,
397            scheduler_cwd,
398            verbose,
399            stderr_color,
400            total_usage,
401            turn_timeout,
402            interrupted,
403        )
404        .await?;
405        return Ok(Ok(content));
406    }
407
408    match api::chat(
409        client,
410        &models.coding,
411        coding_system,
412        user,
413        Some(edit_tl),
414        verbose,
415        stderr_color,
416        stream_stdout,
417        total_usage,
418        turn_timeout,
419        interrupted,
420    )
421    .await
422    {
423        Ok(content) => Ok(Ok(content)),
424        Err(e) if api::is_api_error(&e) => Ok(Err(api::error_summary(&e))),
425        Err(e) => Err(e),
426    }
427}
428
429fn validation_failure_feedback(verdict: &validate::Verdict) -> String {
430    let mut s = format!(
431        "Validation failed at stage `{}`. Fix the current candidate before it is reverted.\n",
432        verdict.stage
433    );
434    if verdict.stage == "runtime" {
435        s.push_str(
436            "The scheduler was not still enabled after the workload, so the candidate likely triggered a sched_ext runtime failure, stall, deadlock, BPF error, or abort and may have measured the default scheduler.\n",
437        );
438    } else if verdict.stage == "metric" {
439        s.push_str(
440            "The workload did not produce a parseable numeric metric. Treat this as a runtime failure of the candidate: the scheduler may have broken, stalled, or disrupted the workload before it could emit a valid score.\n",
441        );
442    }
443    if !verdict.errors.is_empty() {
444        s.push_str("\nErrors:\n");
445        for err in &verdict.errors {
446            s.push_str("- ");
447            s.push_str(err);
448            s.push('\n');
449        }
450    }
451    if let Ok(raw) = serde_json::to_string_pretty(&verdict.raw) {
452        s.push_str("\nVerdict JSON:\n");
453        s.push_str(&raw);
454        s.push('\n');
455    }
456    s
457}
458
459fn should_try_runtime_fix(verdict: &validate::Verdict) -> bool {
460    matches!(verdict.stage.as_str(), "runtime" | "metric")
461}
462
463fn read_attempt_memory(path: &Path) -> Result<Option<String>> {
464    if !path.exists() {
465        return Ok(None);
466    }
467    let text = std::fs::read_to_string(path)
468        .with_context(|| format!("read state file {}", path.display()))?;
469    let text = text.trim();
470    if text.is_empty() {
471        Ok(None)
472    } else {
473        Ok(Some(text.to_string()))
474    }
475}
476
477fn attempt_memory_for_prompt(memory: Option<&str>) -> Option<&str> {
478    memory
479        .map(str::trim)
480        .filter(|s| !s.is_empty())
481        .map(|s| suffix_at_char_boundary(s, 12_000))
482}
483
484fn attempt_summary_line(r: &RoundRecord) -> String {
485    format!(
486        "| {} | {} | {} | {} | {} | {} | {} |\n",
487        r.round,
488        r.outcome.replace('|', "\\|"),
489        r.value
490            .map(|v| format!("{v:.3}"))
491            .unwrap_or_else(|| "-".into()),
492        r.improvement
493            .map(|v| format!("{v:+.3}"))
494            .unwrap_or_else(|| "-".into()),
495        r.policy_area.replace('|', "\\|"),
496        r.direction.replace('|', "\\|"),
497        r.summary.replace('|', "\\|").replace('\n', " "),
498    )
499}
500
501fn run_attempt_summary(
502    rep: &Report,
503    package: &str,
504    crate_dir: &str,
505    spec_path: &Path,
506    baseline_sha: &str,
507) -> String {
508    let ts = SystemTime::now()
509        .duration_since(UNIX_EPOCH)
510        .map(|d| d.as_secs())
511        .unwrap_or_default();
512    let mut s = String::new();
513    s.push_str(&format!("\n## Run {ts}\n\n"));
514    s.push_str(&format!("- Package: `{package}`\n"));
515    s.push_str(&format!("- Crate dir: `{crate_dir}`\n"));
516    s.push_str(&format!("- Spec: `{}`\n", spec_path.display()));
517    s.push_str(&format!("- Baseline: `{baseline_sha}`\n"));
518    s.push_str(&format!(
519        "- Objective: {} `{}`\n",
520        rep.goal, rep.metric_name
521    ));
522    s.push_str(&format!(
523        "- Start: {}\n",
524        rep.start_value
525            .map(|v| format!("{v:.3}"))
526            .unwrap_or_else(|| "-".into())
527    ));
528    s.push_str(&format!(
529        "- Best: {}{}\n\n",
530        rep.best_value
531            .map(|v| format!("{v:.3}"))
532            .unwrap_or_else(|| "-".into()),
533        rep.best_round
534            .map(|r| format!(" (round {r})"))
535            .unwrap_or_default()
536    ));
537    s.push_str("| round | outcome | value | improvement | area | direction | change |\n");
538    s.push_str("|---:|---|---:|---:|---|---|---|\n");
539    for r in &rep.rounds {
540        s.push_str(&attempt_summary_line(r));
541    }
542    s
543}
544
545fn append_attempt_summary(
546    path: &Path,
547    summary: &str,
548    color: color::Style,
549    json: bool,
550) -> Result<()> {
551    if let Some(parent) = path.parent() {
552        std::fs::create_dir_all(parent)
553            .with_context(|| format!("create state file dir {}", parent.display()))?;
554    }
555    let existed = path.exists();
556    let mut file = std::fs::OpenOptions::new()
557        .create(true)
558        .append(true)
559        .open(path)
560        .with_context(|| format!("open state file {}", path.display()))?;
561    if !existed {
562        writeln!(
563            file,
564            "# scx_forge_agent state\n\nThis file is written by scx-forge-agent when `--save` is used and can be loaded into future runs with `--resume` as factual context for future experiments."
565        )
566        .with_context(|| format!("initialize state file {}", path.display()))?;
567    }
568    file.write_all(summary.as_bytes())
569        .with_context(|| format!("write state file {}", path.display()))?;
570    if !json {
571        println!(
572            "{}",
573            color.dim(format!(
574                "[scx-forge-agent] saved attempt summary: {}",
575                path.display()
576            ))
577        );
578    }
579    Ok(())
580}
581
582fn install_ctrl_c_handler(
583    interrupted: Arc<AtomicBool>,
584    color: color::Style,
585    json: bool,
586    source: PathBuf,
587    crate_dir: String,
588) {
589    tokio::spawn(async move {
590        if tokio::signal::ctrl_c().await.is_err() {
591            return;
592        }
593
594        interrupted.store(true, Ordering::SeqCst);
595        if !json {
596            eprintln!(
597                "{}",
598                color.yellow(
599                    "[scx-forge-agent] Ctrl-C received; interrupting the current operation and printing the partial report. Press Ctrl-C again to exit immediately."
600                )
601            );
602        }
603
604        if tokio::signal::ctrl_c().await.is_ok() {
605            if !json {
606                eprintln!(
607                    "{}",
608                    color.red("[scx-forge-agent] second Ctrl-C received; exiting immediately.")
609                );
610            }
611            // Best-effort cleanup before the forced exit: restore the crate to
612            // the last accepted (best) state held in the index, dropping the
613            // in-progress temporary edit, then unstage so the kept changes are
614            // left as plain working-tree modifications - matching the graceful
615            // exit path.
616            git::discard(&source, &crate_dir).ok();
617            git::unstage(&source, &crate_dir).ok();
618            std::process::exit(130);
619        }
620    });
621}
622
623fn interrupted_requested(interrupted: &AtomicBool) -> bool {
624    interrupt::requested(interrupted)
625}
626
627fn build_keep_running_scheduler(
628    source: &Path,
629    package: &str,
630    profile: &str,
631    stdout_color: color::Style,
632    stderr_color: color::Style,
633    json: bool,
634    interrupted: &AtomicBool,
635) -> Result<()> {
636    agent_info(
637        stderr_color,
638        "preparing final scheduler for --keep-running foreground launch...",
639    );
640    cargo_build_with_progress(source, package, profile, !json, stdout_color, interrupted).map_err(
641        |err| {
642            anyhow::anyhow!(
643                "final scheduler build failed for --keep-running:\n{}",
644                suffix_at_char_boundary(&err, 4000)
645            )
646        },
647    )?;
648
649    Ok(())
650}
651
652fn start_keep_running_scheduler(
653    source: &Path,
654    package: &str,
655    profile: &str,
656    sudo: &sudo::Sudo,
657    stderr_color: color::Style,
658) -> Result<()> {
659    agent_info(
660        stderr_color,
661        "starting final scheduler in foreground; press Ctrl-C to stop it",
662    );
663    std::io::stdout()
664        .flush()
665        .context("flush stdout before scheduler launch")?;
666    std::io::stderr()
667        .flush()
668        .context("flush stderr before scheduler launch")?;
669    validate::exec_scheduler_foreground(source, package, profile, sudo)
670}
671
672fn print_final_report(
673    rep: &Report,
674    interrupted: bool,
675    json: bool,
676    stdout_color: color::Style,
677    crate_dir: &str,
678) -> Result<()> {
679    if json {
680        let mut out = rep.to_json();
681        if let Some(obj) = out.as_object_mut()
682            && interrupted
683        {
684            obj.insert("interrupted".to_string(), serde_json::Value::Bool(true));
685        }
686        println!("{}", serde_json::to_string_pretty(&out)?);
687        return Ok(());
688    }
689
690    if interrupted {
691        println!(
692            "{}\n",
693            stdout_color.yellow("Interrupted by Ctrl-C; partial report follows.")
694        );
695    }
696
697    println!("{}", rep.render_markdown());
698    if rep.best_round.is_some() {
699        println!(
700            "\n{}",
701            stdout_color.green(format!(
702                "Winning variant applied in place; review with `git diff -- {}`.",
703                crate_dir
704            ))
705        );
706    } else {
707        println!(
708            "\n{}",
709            stdout_color.yellow("No improvement found; crate left unchanged.")
710        );
711    }
712    // Token usage for the whole run, at the bottom of the output.
713    println!("\n{}", stdout_color.dim(rep.usage.footer_line()));
714
715    Ok(())
716}
717
718fn base_system(package: &str, crate_dir: &str, scheduler_refs: bool) -> String {
719    let refs_sentence = if scheduler_refs {
720        " Read-only tools list_schedulers / grep_schedulers / read_scheduler_file \
721         inspect other scheduler crates under `scheds/rust` for reference."
722    } else {
723        ""
724    };
725    format!(
726        "You are optimizing the sched_ext scheduler `{package}` (the crate at \
727         `{crate_dir}`) to improve a benchmark metric. A deterministic harness \
728         builds, runs, and measures every change and keeps it only if the metric \
729         improves.\n\n\
730         File tools (read_file / list_dir / grep / edit_file) are rooted at the \
731         crate, so paths are relative to the crate root: `src/bpf/main.bpf.c` for \
732         the BPF policy, `src/main.rs` for the control plane.{refs_sentence} All \
733         edits stay inside this crate, and the code must build and pass the BPF \
734         verifier."
735    )
736}
737
738fn planner_system_prompt(
739    package: &str,
740    crate_dir: &str,
741    knob_help: Option<&str>,
742    scheduler_refs: bool,
743) -> String {
744    let mut s = format!(
745        "{}\n\n{OPTIMIZER_MD}\n\n\
746         You are the PLANNER: inspect the crate's code and return one concrete \
747         experiment plan. Do not edit files.",
748        base_system(package, crate_dir, scheduler_refs)
749    );
750    if scheduler_refs {
751        s.push_str(
752            "\n\nThe cross-scheduler reference tools are enabled: you may also \
753             explore other scheduler crates under `scheds/rust` \
754             (list_schedulers / grep_schedulers / read_scheduler_file) and port a \
755             self-consistent mechanism the target lacks, adapting it to this \
756             crate's callbacks, maps, and task lifecycle. Cite which scheduler you \
757             borrowed the idea from.",
758        );
759    }
760    if let Some(help) = knob_help.map(str::trim).filter(|h| !h.is_empty()) {
761        s.push_str(
762            "\n\n## Already-implemented options (do NOT re-propose these)\n\
763             The scheduler already exposes the command-line options below, and an \
764             earlier sweep has tuned them to their best values. They are existing \
765             configuration, not new logic: do not propose changing any of them as \
766             your experiment - it will not improve the metric further. Infer from \
767             this list what has already been tried, and propose a mechanism the \
768             scheduler does not implement yet.\n\n```\n",
769        );
770        s.push_str(help);
771        s.push_str("\n```\n");
772    }
773    s
774}
775
776/// Capture the scheduler's `--help` output so the planner can see which CLI
777/// options (knobs) already exist. This is the always-current, zero-maintenance
778/// source of truth for what the sweep has tuned - no hand-kept signatures.
779/// Returns None if the binary is missing or errors.
780fn scheduler_help_text(repo_root: &Path, package: &str, profile: &str) -> Option<String> {
781    let bin = validate::binary_path(repo_root, package, profile);
782    let out = std::process::Command::new(&bin)
783        .arg("--help")
784        .output()
785        .ok()?;
786    let text = String::from_utf8_lossy(&out.stdout);
787    let text = text.trim();
788    if text.is_empty() {
789        None
790    } else {
791        Some(text.to_string())
792    }
793}
794
795/// Sentinel a knob-phase planner emits when no untested knob value looks
796/// promising any more, telling the controller to move on to code changes.
797const KNOB_PHASE_DONE_SENTINEL: &str = "KNOB_PHASE_COMPLETE";
798
799/// Two stages of the optimization: tune the scheduler's existing configuration
800/// knobs first, then propose new mechanisms. The model drives the transition by
801/// emitting the completion sentinel; both phases share the `[ai].rounds` budget.
802#[derive(Debug, Clone, Copy, PartialEq, Eq)]
803enum Phase {
804    Knob,
805    Mechanism,
806}
807
808/// True if a knob-phase planner reply signals the knob space is exhausted.
809fn knob_phase_complete(plan: &str) -> bool {
810    plan.lines().any(|l| l.trim() == KNOB_PHASE_DONE_SENTINEL)
811}
812
813/// Assemble the scheduler-agnostic knob inventory the knob-phase model tunes
814/// from: the binary's `--help` (every option, its default, and - for enums -
815/// its possible values, rendered by clap). This needs no per-scheduler
816/// hand-maintenance, works for any `scheds/rust/<package>` crate, and can never
817/// go stale relative to the options the scheduler actually exposes.
818fn knob_inventory(repo_root: &Path, package: &str, profile: &str) -> String {
819    let mut s = String::new();
820    if let Some(help) = scheduler_help_text(repo_root, package, profile) {
821        s.push_str("## Command-line options (from --help)\n```\n");
822        s.push_str(&help);
823        s.push_str("\n```\n");
824    }
825    s
826}
827
828/// System prompt for the KNOB-TUNING phase: the model picks one existing option
829/// per round and changes its default to a single untested value, or signals the
830/// phase is done. It never writes new logic here.
831fn knob_system_prompt(package: &str, crate_dir: &str, inventory: &str) -> String {
832    let mut s = format!(
833        "{}\n\n{}",
834        base_system(package, crate_dir, false),
835        KNOB_MD.trim()
836    );
837    s.push_str(&format!(
838        "\n\n\
839         When no untested option value looks promising (the knob space is \
840         effectively exhausted), reply with exactly `{KNOB_PHASE_DONE_SENTINEL}` \
841         on its own line and nothing else."
842    ));
843    s.push_str(
844        "\n\n\
845         Do not edit files; return the plan only.",
846    );
847    let inv = inventory.trim();
848    if !inv.is_empty() {
849        s.push_str("\n\n## Available configuration options\n");
850        s.push_str(inv);
851    }
852    s
853}
854
855fn coding_system_prompt(package: &str, crate_dir: &str, scheduler_refs: bool) -> String {
856    format!(
857        "{}\n\n\
858         You are the CODER: implement the experiment described in the user \
859         message with the `edit_file` tool, keeping the edit focused and \
860         preserving sched_ext lifecycle semantics. Follow the BPF safety rules in \
861         the reference below; in particular split chained lookups like \
862         `lookup_x(...)->field` into a pointer, a NULL check, then the deref.\n\n\
863         Every change must alter scheduling behavior in this same edit. If you \
864         add a struct field, map, global, or local, make sure it is BOTH read \
865         and written by the code within the same change: read-only or write-only \
866         state produces no change to scheduler behavior, wastes the round, and is \
867         rejected. The value you write must flow into a decision that affects \
868         placement, ordering, dispatch, vtime/deadline, slice, or preemption. \
869         When done, reply with a one-line summary of the change.\n\n\
870         --- BEGIN SKILL.md (sched_ext domain reference) ---\n\n{SKILL_MD}\n\n\
871         --- END SKILL.md ---\n",
872        base_system(package, crate_dir, scheduler_refs)
873    )
874}
875
876/// Normalize a `git diff` for dedup: drop the `index <hash>..<hash>` lines whose
877/// blob hashes are noise, so two textually identical changes compare equal.
878fn normalize_diff(diff: &str) -> String {
879    diff.lines()
880        .filter(|l| !l.starts_with("index "))
881        .collect::<Vec<_>>()
882        .join("\n")
883        .trim()
884        .to_string()
885}
886
887fn prefix_at_char_boundary(s: &str, max_bytes: usize) -> &str {
888    if s.len() <= max_bytes {
889        return s;
890    }
891    let end = s
892        .char_indices()
893        .map(|(idx, _)| idx)
894        .take_while(|idx| *idx <= max_bytes)
895        .last()
896        .unwrap_or(0);
897    &s[..end]
898}
899
900fn suffix_at_char_boundary(s: &str, max_bytes: usize) -> &str {
901    if s.len() <= max_bytes {
902        return s;
903    }
904    let target = s.len() - max_bytes;
905    let start = s
906        .char_indices()
907        .map(|(idx, _)| idx)
908        .find(|idx| *idx >= target)
909        .unwrap_or(s.len());
910    &s[start..]
911}
912
913fn normalize_attempt_summary(summary: &str) -> Option<String> {
914    let normalized = summary
915        .chars()
916        .map(|ch| {
917            if ch.is_ascii_alphanumeric() {
918                ch.to_ascii_lowercase()
919            } else {
920                ' '
921            }
922        })
923        .collect::<String>()
924        .split_whitespace()
925        .collect::<Vec<_>>()
926        .join(" ");
927
928    if normalized.is_empty() || normalized == "no summary" {
929        None
930    } else {
931        Some(normalized)
932    }
933}
934
935fn planner_plan_rejection_reason(plan: &str) -> Option<&'static str> {
936    let trimmed = plan.trim();
937    if trimmed.is_empty() {
938        return Some("empty planner response");
939    }
940
941    let lower = trimmed.to_ascii_lowercase();
942    if lower.starts_with("error:")
943        || lower.starts_with("failed:")
944        || lower.contains("error: unknown tool")
945        || lower.contains("unknown tool:")
946    {
947        return Some("planner returned a tool error instead of a plan");
948    }
949
950    None
951}
952
953fn planner_recovery_note(reason: &str) -> String {
954    format!(
955        "The previous planner turn did not produce a usable plan ({reason}). \
956         Tool failures and unknown tools are recoverable. Do not stop planning, \
957         do not switch to coding, and do not call edit tools. Continue with the \
958         advertised read-only tools and the context already gathered, then return \
959         the concrete experiment plan now."
960    )
961}
962
963#[derive(Debug, Clone, PartialEq, Eq)]
964struct AttemptTags {
965    policy_area: String,
966    direction: String,
967}
968
969fn contains_any(text: &str, needles: &[&str]) -> bool {
970    needles.iter().any(|needle| text.contains(needle))
971}
972
973fn classify_policy_area(text: &str) -> &'static str {
974    if contains_any(
975        text,
976        &[
977            "select_cpu",
978            "pick_cpu",
979            "prev_cpu",
980            "idle cpu",
981            "idle_cpu",
982            "wakeup cpu",
983            "placement",
984            "affinity",
985            "migrate",
986        ],
987    ) {
988        "placement"
989    } else if contains_any(
990        text,
991        &[
992            "vtime",
993            "vruntime",
994            "deadline",
995            "lag",
996            "dsq_insert_vtime",
997            "virtual time",
998        ],
999    ) {
1000        "vtime"
1001    } else if contains_any(text, &["preempt", "kick", "resched", "yield", "yielding"]) {
1002        "preemption"
1003    } else if contains_any(text, &["slice", "quantum", "timeslice"]) {
1004        "slice"
1005    } else if contains_any(
1006        text,
1007        &[
1008            "dispatch",
1009            "consume",
1010            "move_to_local",
1011            "pull",
1012            "steal",
1013            "drain",
1014        ],
1015    ) {
1016        "dispatch"
1017    } else if contains_any(
1018        text,
1019        &[
1020            "topology", "domain", "llc", "numa", "node", "cpumask", "cluster",
1021        ],
1022    ) {
1023        "topology"
1024    } else if contains_any(
1025        text,
1026        &[
1027            "load balance",
1028            "load_balance",
1029            "queue depth",
1030            "nr_queued",
1031            "nr_running",
1032            "runnable",
1033        ],
1034    ) {
1035        "load_balance"
1036    } else if contains_any(
1037        text,
1038        &[
1039            "classify",
1040            "interactive",
1041            "latency",
1042            "batch",
1043            "weight",
1044            "per-task",
1045            "per task",
1046            "per-cpu",
1047            "per cpu",
1048        ],
1049    ) {
1050        "classification"
1051    } else {
1052        "unknown"
1053    }
1054}
1055
1056fn direction_stopword(word: &str) -> bool {
1057    matches!(
1058        word,
1059        "a" | "an"
1060            | "and"
1061            | "as"
1062            | "by"
1063            | "for"
1064            | "from"
1065            | "in"
1066            | "into"
1067            | "of"
1068            | "on"
1069            | "or"
1070            | "the"
1071            | "to"
1072            | "with"
1073            | "changed"
1074            | "change"
1075            | "modified"
1076            | "modify"
1077            | "added"
1078            | "add"
1079            | "updated"
1080            | "update"
1081            | "use"
1082            | "uses"
1083            | "using"
1084            | "should"
1085            | "metric"
1086            | "score"
1087    )
1088}
1089
1090fn classify_attempt(summary: &str, diff: &str) -> AttemptTags {
1091    let combined = format!("{summary}\n{diff}").to_ascii_lowercase();
1092    let area = classify_policy_area(&combined).to_string();
1093    let direction_src = if summary.trim().is_empty() {
1094        diff.lines().take(40).collect::<Vec<_>>().join(" ")
1095    } else {
1096        summary.to_string()
1097    };
1098    let mut words = normalize_attempt_summary(&direction_src)
1099        .unwrap_or_else(|| area.clone())
1100        .split_whitespace()
1101        .filter(|w| !direction_stopword(w))
1102        .take(8)
1103        .map(ToString::to_string)
1104        .collect::<Vec<_>>();
1105    if words.is_empty() {
1106        words.push(area.clone());
1107    }
1108    AttemptTags {
1109        policy_area: area,
1110        direction: words.join("_"),
1111    }
1112}
1113
1114fn normalized_improvement(goal: &str, delta: f64) -> f64 {
1115    if goal == "minimize" { -delta } else { delta }
1116}
1117
1118#[allow(clippy::too_many_arguments)]
1119fn round_record(
1120    round: u32,
1121    summary: String,
1122    outcome: &str,
1123    value: Option<f64>,
1124    delta: Option<f64>,
1125    kept: bool,
1126    goal: &str,
1127    diff: &str,
1128) -> RoundRecord {
1129    let tags = classify_attempt(&summary, diff);
1130    RoundRecord {
1131        round,
1132        summary,
1133        outcome: outcome.to_string(),
1134        value,
1135        delta,
1136        improvement: delta.map(|d| normalized_improvement(goal, d)),
1137        policy_area: tags.policy_area,
1138        direction: tags.direction,
1139        kept,
1140    }
1141}
1142
1143fn record_interrupted_round(
1144    rep: &mut Report,
1145    source: &Path,
1146    crate_dir: &str,
1147    round: u32,
1148    summary: String,
1149    goal: &str,
1150) {
1151    let attempt_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
1152    git::discard(source, crate_dir).ok();
1153    rep.rounds.push(round_record(
1154        round,
1155        summary,
1156        "interrupted",
1157        None,
1158        None,
1159        false,
1160        goal,
1161        &attempt_diff,
1162    ));
1163}
1164
1165fn record_model_timeout_round(
1166    rep: &mut Report,
1167    source: &Path,
1168    crate_dir: &str,
1169    round: u32,
1170    summary: String,
1171    goal: &str,
1172    made_edit: bool,
1173) {
1174    let attempt_diff = if made_edit {
1175        git::worktree_diff(source, crate_dir).unwrap_or_default()
1176    } else {
1177        String::new()
1178    };
1179    git::discard(source, crate_dir).ok();
1180    rep.rounds.push(round_record(
1181        round,
1182        summary,
1183        "model-timeout",
1184        None,
1185        None,
1186        false,
1187        goal,
1188        &attempt_diff,
1189    ));
1190}
1191
1192fn diff_file_path(line: &str) -> Option<String> {
1193    if !line.starts_with("diff --git ") {
1194        return None;
1195    }
1196    let path = line.split_whitespace().nth(3)?;
1197    Some(path.strip_prefix("b/").unwrap_or(path).to_string())
1198}
1199
1200fn bpf_const_volatile_initializer_name(diff_line: &str) -> Option<String> {
1201    if !(diff_line.starts_with('+') || diff_line.starts_with('-'))
1202        || diff_line.starts_with("+++")
1203        || diff_line.starts_with("---")
1204    {
1205        return None;
1206    }
1207    let code = diff_line.get(1..)?.trim();
1208    if !code.starts_with("const volatile ") || !code.ends_with(';') {
1209        return None;
1210    }
1211    let lhs = code.split_once('=')?.0.trim();
1212    let name = lhs.split_whitespace().last()?;
1213    Some(
1214        name.split_once('[')
1215            .map_or(name, |(base, _)| base)
1216            .to_string(),
1217    )
1218}
1219
1220fn bpf_const_volatile_initializer_only_vars(diff: &str) -> Option<Vec<String>> {
1221    let mut current_file = None;
1222    let mut vars = BTreeSet::new();
1223
1224    for line in diff.lines() {
1225        if let Some(path) = diff_file_path(line) {
1226            current_file = Some(path);
1227            continue;
1228        }
1229        if !(line.starts_with('+') || line.starts_with('-'))
1230            || line.starts_with("+++")
1231            || line.starts_with("---")
1232        {
1233            continue;
1234        }
1235        let file = current_file.as_deref()?;
1236        if !file.ends_with(".bpf.c") {
1237            return None;
1238        }
1239        let name = bpf_const_volatile_initializer_name(line)?;
1240        vars.insert(name);
1241    }
1242
1243    if vars.is_empty() {
1244        None
1245    } else {
1246        Some(vars.into_iter().collect())
1247    }
1248}
1249
1250fn ineffective_round_edit_reason(diff: &str) -> Option<String> {
1251    let vars = bpf_const_volatile_initializer_only_vars(diff)?;
1252    Some(format!(
1253        "only changed BPF const volatile initializer(s): {}. Rust rodata assignments usually overwrite these defaults before load.",
1254        vars.join(", ")
1255    ))
1256}
1257
1258fn ineffective_round_note(reason: &str) -> String {
1259    format!(
1260        "Your last edit was rejected before validation because it {reason} If tuning one of these knobs, update the Rust-side default / CLI plumbing / rodata_data assignment in src/main.rs in the same edit. Otherwise choose a runtime scheduling policy change such as task placement, queue ordering, DSQ selection/topology, dispatch pulling, deadline/vtime calculation, or preemption/kick behavior."
1261    )
1262}
1263
1264fn history_table(rounds: &[RoundRecord]) -> String {
1265    if rounds.is_empty() {
1266        return "(no rounds yet)".to_string();
1267    }
1268    let mut s =
1269        String::from("round | outcome | value | improvement | area | direction | kept | change\n");
1270    for r in rounds {
1271        s.push_str(&format!(
1272            "{} | {} | {} | {} | {} | {} | {} | {}\n",
1273            r.round,
1274            r.outcome,
1275            r.value
1276                .map(|v| format!("{v:.3}"))
1277                .unwrap_or_else(|| "-".into()),
1278            r.improvement
1279                .map(|v| format!("{v:+.3}"))
1280                .unwrap_or_else(|| "-".into()),
1281            r.policy_area,
1282            r.direction,
1283            r.kept,
1284            r.summary,
1285        ));
1286    }
1287    s
1288}
1289
1290#[allow(clippy::too_many_arguments)]
1291fn round_context_prompt(
1292    metric_name: &str,
1293    goal: &str,
1294    goal_description: Option<&str>,
1295    best_value: Option<f64>,
1296    history: &[RoundRecord],
1297    attempt_memory: Option<&str>,
1298    last_verdict_json: &str,
1299    crate_diff: &str,
1300    build_errors: Option<&str>,
1301    note: Option<&str>,
1302) -> String {
1303    let mut s = String::new();
1304    if let Some(d) = goal_description {
1305        s.push_str(&format!("Goal: {d}\n"));
1306    }
1307    s.push_str(&format!("Concretely, {goal} the metric `{metric_name}`.\n"));
1308    if let Some(b) = best_value {
1309        s.push_str(&format!("Best `{metric_name}` so far (accepted): {b:.3}\n"));
1310    }
1311    s.push_str(
1312        "Only changes that improve the metric this round are kept; non-improving \
1313         rounds are reverted. Make one self-contained change that affects \
1314         scheduling behavior immediately.\n",
1315    );
1316    if let Some(memory) = attempt_memory_for_prompt(attempt_memory) {
1317        s.push_str("\n## Previous run attempt memory\n");
1318        s.push_str(memory);
1319        s.push('\n');
1320    }
1321    s.push_str("\n## Optimization history\n");
1322    s.push_str(&history_table(history));
1323
1324    s.push_str("\n## Last benchmark result (JSON)\n");
1325    s.push_str(prefix_at_char_boundary(last_verdict_json, 4000));
1326    s.push('\n');
1327
1328    s.push_str("\n## Currently applied changes (diff vs baseline)\n");
1329    if crate_diff.trim().is_empty() {
1330        s.push_str("(none yet - the scheduler is at its baseline)\n");
1331    } else {
1332        s.push_str("```diff\n");
1333        s.push_str(prefix_at_char_boundary(crate_diff, 6000));
1334        s.push_str("\n```\n");
1335    }
1336
1337    if let Some(err) = build_errors {
1338        s.push_str("\n## The previous build or validation FAILED. Fix it with one edit.\n```\n");
1339        s.push_str(suffix_at_char_boundary(err, 4000));
1340        s.push_str("\n```\n");
1341    }
1342
1343    if let Some(n) = note {
1344        s.push_str(&format!("\n## Note\n{n}\n"));
1345    }
1346
1347    s
1348}
1349
1350#[allow(clippy::too_many_arguments)]
1351fn planner_prompt(
1352    metric_name: &str,
1353    goal: &str,
1354    goal_description: Option<&str>,
1355    best_value: Option<f64>,
1356    history: &[RoundRecord],
1357    attempt_memory: Option<&str>,
1358    last_verdict_json: &str,
1359    crate_diff: &str,
1360    build_errors: Option<&str>,
1361    note: Option<&str>,
1362) -> String {
1363    let mut s = round_context_prompt(
1364        metric_name,
1365        goal,
1366        goal_description,
1367        best_value,
1368        history,
1369        attempt_memory,
1370        last_verdict_json,
1371        crate_diff,
1372        build_errors,
1373        note,
1374    );
1375
1376    s.push_str(
1377        "\nReturn the experiment plan now (do not edit files). Make it specific \
1378         enough for the coding model to implement verbatim, and include a one-line \
1379         summary, the source scheduler you borrowed from, the files/functions to \
1380         change, the exact mechanism, and the expected effect on the metric. If a \
1381         tool errors or is missing, keep going and still return the plan.\n",
1382    );
1383
1384    s
1385}
1386
1387/// The coder is handed exactly the planner's plan - nothing else. Build-fix and
1388/// runtime-fix rounds have no plan, so they fall back to the round context plus
1389/// the failure to repair.
1390#[allow(clippy::too_many_arguments)]
1391fn edit_prompt(
1392    metric_name: &str,
1393    goal: &str,
1394    goal_description: Option<&str>,
1395    best_value: Option<f64>,
1396    history: &[RoundRecord],
1397    attempt_memory: Option<&str>,
1398    last_verdict_json: &str,
1399    crate_diff: &str,
1400    build_errors: Option<&str>,
1401    note: Option<&str>,
1402    plan: Option<&str>,
1403) -> String {
1404    match plan.filter(|p| !p.trim().is_empty()) {
1405        // Normal round: the user message is the plan itself, nothing more.
1406        Some(plan) => format!(
1407            "Implement this experiment with the edit_file tool, then reply with a \
1408             one-line summary of the change.\n\n{}",
1409            prefix_at_char_boundary(plan, 6000).trim()
1410        ),
1411        // Fix round: give the coder the round context and the failure to repair.
1412        None => {
1413            let mut s = round_context_prompt(
1414                metric_name,
1415                goal,
1416                goal_description,
1417                best_value,
1418                history,
1419                attempt_memory,
1420                last_verdict_json,
1421                crate_diff,
1422                build_errors,
1423                note,
1424            );
1425            s.push_str(
1426                "\nMake the edit you believe will improve the metric now (or fix \
1427                 the failure above), then reply with a one-line summary of the change.\n",
1428            );
1429            s
1430        }
1431    }
1432}
1433
1434/// Knob-phase planner prompt: the round context plus an instruction to pick one
1435/// option to retune (or end the phase). The available options live in the
1436/// knob-phase system prompt, not here.
1437#[allow(clippy::too_many_arguments)]
1438fn knob_planner_prompt(
1439    metric_name: &str,
1440    goal: &str,
1441    goal_description: Option<&str>,
1442    best_value: Option<f64>,
1443    history: &[RoundRecord],
1444    attempt_memory: Option<&str>,
1445    last_verdict_json: &str,
1446    crate_diff: &str,
1447    note: Option<&str>,
1448) -> String {
1449    let mut s = round_context_prompt(
1450        metric_name,
1451        goal,
1452        goal_description,
1453        best_value,
1454        history,
1455        attempt_memory,
1456        last_verdict_json,
1457        crate_diff,
1458        None,
1459        note,
1460    );
1461    s.push_str(&format!(
1462        "\nPick the single most promising UNTESTED option value and return the \
1463         plan now (do not edit files): name the option, the exact new default \
1464         value, and the one-line `default_value`/`default_value_t` edit in \
1465         `src/main.rs` the coder should make. If every option value worth trying \
1466         has already been tested, reply with exactly `{KNOB_PHASE_DONE_SENTINEL}` \
1467         and nothing else.\n"
1468    ));
1469    s
1470}
1471
1472/// Knob-phase coder prompt. With a planner plan (openai backend) it just asks
1473/// the coder to apply that one-line default change. Without a plan (subprocess
1474/// backends, which have no separate planner) it carries the round context plus
1475/// the knob inventory so the coder can pick and apply a knob itself.
1476#[allow(clippy::too_many_arguments)]
1477fn knob_edit_prompt(
1478    metric_name: &str,
1479    goal: &str,
1480    goal_description: Option<&str>,
1481    best_value: Option<f64>,
1482    history: &[RoundRecord],
1483    attempt_memory: Option<&str>,
1484    last_verdict_json: &str,
1485    crate_diff: &str,
1486    note: Option<&str>,
1487    plan: Option<&str>,
1488    inventory: &str,
1489) -> String {
1490    const GUARDRAIL: &str = "Add or edit only the option's clap default in the \
1491        `Opts` struct in `src/main.rs` (the `default_value_t`/`default_value` \
1492        attribute). Do NOT touch the `to_bpf()` match arms or any BPF code.";
1493    match plan.filter(|p| !p.trim().is_empty()) {
1494        Some(plan) => format!(
1495            "Apply this knob change with the edit_file tool, then reply with a \
1496             one-line summary. {GUARDRAIL}\n\n{}",
1497            prefix_at_char_boundary(plan, 6000).trim()
1498        ),
1499        None => {
1500            let mut s = round_context_prompt(
1501                metric_name,
1502                goal,
1503                goal_description,
1504                best_value,
1505                history,
1506                attempt_memory,
1507                last_verdict_json,
1508                crate_diff,
1509                None,
1510                note,
1511            );
1512            let inv = inventory.trim();
1513            if !inv.is_empty() {
1514                s.push_str("\n## Available configuration options\n");
1515                s.push_str(inv);
1516                s.push('\n');
1517            }
1518            s.push_str(&format!(
1519                "\nRetune ONE existing option to a single untested value you \
1520                 expect will improve the metric, then reply with a one-line \
1521                 summary. {GUARDRAIL}\n"
1522            ));
1523            s
1524        }
1525    }
1526}
1527
1528#[allow(clippy::too_many_arguments)]
1529async fn build_with_fix_loop(
1530    args: &OptimizeArgs,
1531    build_fix_attempts: u32,
1532    source: &Path,
1533    package: &str,
1534    profile: &str,
1535    crate_dir: &str,
1536    baseline_sha: &str,
1537    metric_name: &str,
1538    goal: &str,
1539    goal_description: Option<&str>,
1540    best_value: f64,
1541    history: &[RoundRecord],
1542    attempt_memory: Option<&str>,
1543    last_verdict_json: &str,
1544    client: &reqwest::Client,
1545    models: &config::ModelRoles,
1546    coding_system: &str,
1547    scheduler_cwd: &Path,
1548    edit_tl: &api::ToolLoopConfig,
1549    stdout_color: color::Style,
1550    stderr_color: color::Style,
1551    active_model: &mut ActiveModelPrinter,
1552    total_usage: &mut usage::Usage,
1553    turn_timeout: Duration,
1554    interrupted: Arc<AtomicBool>,
1555) -> Result<Option<String>> {
1556    let mut build_err = cargo_build_with_progress(
1557        source,
1558        package,
1559        profile,
1560        !args.json,
1561        stdout_color,
1562        &interrupted,
1563    )
1564    .err();
1565    let mut fix = 0;
1566    if interrupted_requested(&interrupted) {
1567        return Ok(build_err.or_else(|| Some("interrupted by Ctrl-C".to_string())));
1568    }
1569    while build_err.is_some() && fix < build_fix_attempts && !interrupted_requested(&interrupted) {
1570        fix += 1;
1571        agent_warn(
1572            stderr_color,
1573            format!("build failed; fix attempt {fix}/{}", build_fix_attempts),
1574        );
1575        let diff_now = git::diff(source, baseline_sha, crate_dir).unwrap_or_default();
1576        let up = edit_prompt(
1577            metric_name,
1578            goal,
1579            goal_description,
1580            Some(best_value),
1581            history,
1582            attempt_memory,
1583            last_verdict_json,
1584            &diff_now,
1585            build_err.as_deref(),
1586            None,
1587            None,
1588        );
1589        let before_fix = normalize_diff(&diff_now);
1590        let fix_turn = run_coding_turn(
1591            client,
1592            models,
1593            coding_system,
1594            &up,
1595            scheduler_cwd,
1596            edit_tl,
1597            args.verbose,
1598            !args.json,
1599            stderr_color,
1600            active_model,
1601            total_usage,
1602            turn_timeout,
1603            interrupted.clone(),
1604        )
1605        .await;
1606        let fix_attempted = match fix_turn {
1607            Err(e) if interrupt::is(&e) => {
1608                return Ok(Some("interrupted by Ctrl-C".to_string()));
1609            }
1610            Err(e) => return Err(e),
1611            Ok(Ok(_)) => true,
1612            Ok(Err(msg)) => {
1613                let after_fix =
1614                    normalize_diff(&git::diff(source, baseline_sha, crate_dir).unwrap_or_default());
1615                if after_fix != before_fix {
1616                    agent_warn(
1617                        stderr_color,
1618                        format!("API error after build-fix edit; retrying build: {msg}"),
1619                    );
1620                    true
1621                } else {
1622                    agent_error(
1623                        stderr_color,
1624                        format!("API error during build-fix attempt; reverting round: {msg}"),
1625                    );
1626                    false
1627                }
1628            }
1629        };
1630        if !fix_attempted {
1631            break;
1632        }
1633        build_err = cargo_build_with_progress(
1634            source,
1635            package,
1636            profile,
1637            !args.json,
1638            stdout_color,
1639            &interrupted,
1640        )
1641        .err();
1642    }
1643    Ok(build_err)
1644}
1645
1646#[tokio::main]
1647async fn main() -> Result<()> {
1648    optimize(OptimizeArgs::parse()).await
1649}
1650
1651async fn optimize(args: OptimizeArgs) -> Result<()> {
1652    let stderr_color = color::Style::stderr(args.no_color);
1653    let stdout_color = color::Style::stdout(args.no_color);
1654    let source = resolve_repo_root(&args.source)?;
1655
1656    // The validation harness is built into this binary; the spec it reads lives
1657    // with the agent and is the single source of truth for which scheduler to
1658    // optimize. Resolve and parse it first so the package/profile/crate_dir below
1659    // match exactly what the harness will build and run.
1660    let default_spec = source.join("tools/scx_forge_agent/spec.toml");
1661    let spec_path = args.spec.clone().unwrap_or_else(|| default_spec.clone());
1662    if !spec_path.is_file() {
1663        anyhow::bail!(
1664            "spec not found: {}\nThe default spec is {}; pass --spec or create it.",
1665            spec_path.display(),
1666            default_spec.display(),
1667        );
1668    }
1669    let spec = spec::Spec::load(&spec_path)
1670        .with_context(|| format!("load spec {}", spec_path.display()))?;
1671    let package = spec.scheduler.package.clone();
1672    if package.trim().is_empty() {
1673        anyhow::bail!("spec [scheduler].package is empty; set a crate name, e.g. scx_cosmos");
1674    }
1675    let profile = spec.scheduler.profile.clone();
1676    let accept_threshold_stddev = spec.goal.accept_threshold_stddev;
1677    // Derive the crate dir from the package by convention (scheds/rust/<package>);
1678    // --crate-dir overrides only the on-disk location, never which package is built.
1679    let crate_dir = args
1680        .crate_dir
1681        .clone()
1682        .unwrap_or_else(|| format!("scheds/rust/{package}"));
1683    let crate_dir_abs = source.join(&crate_dir);
1684    if !crate_dir_abs.is_dir() {
1685        anyhow::bail!("crate dir not found: {}", crate_dir_abs.display());
1686    }
1687    // Cross-scheduler reference tools are opt-in ([ai].cross_scheduler_refs). When
1688    // disabled, scheds_root stays None so the tools are neither advertised nor
1689    // dispatchable, and the prompts omit any mention of other schedulers.
1690    let scheds_root = if spec.ai.cross_scheduler_refs {
1691        let scheds_root = source.join("scheds/rust");
1692        scheds_root.is_dir().then_some(scheds_root)
1693    } else {
1694        None
1695    };
1696    // Each role's backend is selected by its `backend` value in the spec
1697    // ([ai].backend / [ai].coding_backend): a URL -> openai, or one of the
1698    // keywords claude/codex/opencode/cursor-agent -> that subprocess CLI.
1699    let models = config::resolve_roles_from_env(
1700        spec.ai.backend.as_deref(),
1701        spec.ai.model.as_deref(),
1702        spec.ai.coding_backend.as_deref(),
1703        spec.ai.coding_model.as_deref(),
1704    )?;
1705    let attempt_memory = match args.resume.as_deref() {
1706        Some(path) => read_attempt_memory(path)?,
1707        None => None,
1708    };
1709
1710    let planner_system = planner_system_prompt(
1711        &package,
1712        &crate_dir,
1713        scheduler_help_text(&source, &package, &profile).as_deref(),
1714        scheds_root.is_some(),
1715    );
1716    let coding_system = coding_system_prompt(&package, &crate_dir, scheds_root.is_some());
1717
1718    // --- dry-run: show the assembled context and plan, touch nothing. ---
1719    if args.dry_run {
1720        let sample_plan_user = planner_prompt(
1721            "<metric>",
1722            "<goal>",
1723            Some("<plain-language goal from spec [goal].prompt>"),
1724            None,
1725            &[],
1726            attempt_memory.as_deref(),
1727            "{ (a baseline run would appear here) }",
1728            "",
1729            None,
1730            None,
1731        );
1732        let sample_edit_user = edit_prompt(
1733            "<metric>",
1734            "<goal>",
1735            Some("<plain-language goal from spec [goal].prompt>"),
1736            None,
1737            &[],
1738            attempt_memory.as_deref(),
1739            "{ (a baseline run would appear here) }",
1740            "",
1741            None,
1742            None,
1743            Some("<planner output would appear here>"),
1744        );
1745        println!("=== resolved paths ===");
1746        println!("source        : {}", source.display());
1747        println!("package       : {package}");
1748        println!("crate_dir     : {}", crate_dir_abs.display());
1749        println!(
1750            "scheds_root   : {}",
1751            scheds_root
1752                .as_ref()
1753                .map(|p| p.display().to_string())
1754                .unwrap_or_else(|| "(cross-scheduler refs disabled)".to_string())
1755        );
1756        println!("profile       : {profile}");
1757        println!(
1758            "planner       : {} ({})",
1759            model_display_name(&models.planner),
1760            model_backend_label(&models.planner)
1761        );
1762        println!(
1763            "coding        : {} ({})",
1764            model_display_name(&models.coding),
1765            model_backend_label(&models.coding)
1766        );
1767        println!("cargo         : {}", cargo_program().to_string_lossy());
1768        println!("spec          : {}", spec_path.display());
1769        println!(
1770            "tracing       : {}",
1771            if spec.tracing.enable_tracing {
1772                "enabled when perf is available"
1773            } else {
1774                "disabled"
1775            }
1776        );
1777        println!(
1778            "resume file   : {}",
1779            args.resume
1780                .as_ref()
1781                .map(|p| p.display().to_string())
1782                .unwrap_or_else(|| "(disabled)".to_string())
1783        );
1784        println!(
1785            "save file     : {}",
1786            args.save
1787                .as_ref()
1788                .map(|p| p.display().to_string())
1789                .unwrap_or_else(|| "(disabled)".to_string())
1790        );
1791        println!(
1792            "keep-running  : {}",
1793            if args.keep_running {
1794                "enabled"
1795            } else {
1796                "disabled"
1797            }
1798        );
1799        println!(
1800            "attempt memory: {}",
1801            attempt_memory
1802                .as_ref()
1803                .map(|s| format!("{} bytes loaded", s.len()))
1804                .unwrap_or_else(|| "(none loaded)".to_string())
1805        );
1806        println!(
1807            "sudo pass file: {}",
1808            spec_sudo_password_file(&spec_path, &spec)
1809                .map(|p| {
1810                    p.map(|p| p.display().to_string())
1811                        .unwrap_or_else(|| "(not configured)".to_string())
1812                })
1813                .unwrap_or_else(|e| format!("ERROR: {e:#}"))
1814        );
1815        println!("rounds        : {}", spec.ai.rounds);
1816        println!(
1817            "\n=== planner system prompt ({} chars) ===",
1818            planner_system.len()
1819        );
1820        println!("{planner_system}");
1821        println!(
1822            "\n=== coding system prompt ({} chars) ===",
1823            coding_system.len()
1824        );
1825        println!("{coding_system}");
1826        println!("\n=== example planner user prompt ===\n{sample_plan_user}");
1827        println!("\n=== example coding user prompt ===\n{sample_edit_user}");
1828        let refs_tools = if scheds_root.is_some() {
1829            "; read-only scheduler-reference tools: list_schedulers/grep_schedulers/read_scheduler_file"
1830        } else {
1831            ""
1832        };
1833        let keep_running_step = if args.keep_running {
1834            "\n  6. restore the final accepted source, rebuild it, print the report, then exec the scheduler in the foreground"
1835        } else {
1836            ""
1837        };
1838        println!(
1839            "\n=== planned loop ===\n\
1840             round 0: validate baseline (current crate) -> seed best value & objective\n\
1841             each round 1..={}:\n  1. planner/reasoner chooses ONE experiment with read-only tools (openai backend, including host topology tools lscpu_e/numactl_hardware/cpu_cache_sizes)\n  \
1842             2. coding model applies the edit (target tools: rg when available/grep/read_file/list_dir/edit_file{refs_tools}; host topology tools: lscpu_e/numactl_hardware/cpu_cache_sizes)\n  \
1843             3. {} build --profile {} -p {} (fix loop up to {} attempts)\n  \
1844             4. built-in harness builds+runs spec {} -> verdict (runtime-fix loop up to {} attempts)\n  \
1845             5. keep iff improves by > {} * stddev (stage edit in place), else revert (restore from last accepted){keep_running_step}\n\
1846             The crate is edited in place on the current branch (no branch, no commits); the\n\
1847             winning variant is left as uncommitted working-tree changes.\n",
1848            spec.ai.rounds,
1849            cargo_program().to_string_lossy(),
1850            profile,
1851            package,
1852            spec.ai.build_fix_attempts,
1853            spec_path.display(),
1854            spec.ai.runtime_fix_attempts,
1855            accept_threshold_stddev,
1856        );
1857        return Ok(());
1858    }
1859
1860    // The harness loads the scheduler as root. If the scheduler spec provides a
1861    // sudo password file, export it so the harness (and its teardown pkills)
1862    // can authenticate without requiring passwordless sudo.
1863    let sudo_password_file = sudo_password_file_from_spec(&spec_path, &spec)?;
1864    let sudo = sudo::Sudo::resolve(sudo_password_file.as_deref())?;
1865
1866    let client = http::build_http_client()?;
1867    let interrupted = Arc::new(AtomicBool::new(false));
1868
1869    // --- git setup: require a clean crate dir, record the baseline. ---
1870    // We edit the crate in place on the current branch (no branch, no commits)
1871    // and use the index as the keep/revert checkpoint.
1872    git::ensure_clean(&source, &crate_dir)?;
1873    let baseline_sha = git::rev_parse(&source, "HEAD")?;
1874
1875    // Install the handler after the clean check so that even an immediate
1876    // (double Ctrl-C) exit restores the crate to the last accepted state and
1877    // unstages it, keeping the optimal changes and dropping the temporary one.
1878    install_ctrl_c_handler(
1879        interrupted.clone(),
1880        stderr_color,
1881        args.json,
1882        source.clone(),
1883        crate_dir.clone(),
1884    );
1885
1886    agent_info(
1887        stderr_color,
1888        format!(
1889            "editing {} in place (baseline {})",
1890            crate_dir,
1891            &baseline_sha[..baseline_sha.len().min(12)]
1892        ),
1893    );
1894
1895    let run = optimize_loop(
1896        &args,
1897        &source,
1898        &spec_path,
1899        &spec,
1900        &package,
1901        &profile,
1902        &crate_dir,
1903        &sudo,
1904        &client,
1905        &models,
1906        &baseline_sha,
1907        scheds_root.as_deref(),
1908        attempt_memory.as_deref(),
1909        interrupted.clone(),
1910    );
1911    let result = run.await;
1912
1913    match result {
1914        Ok(outcome) => {
1915            let OptimizeOutcome {
1916                report: rep,
1917                interrupted: was_interrupted,
1918            } = outcome;
1919            // Leave the winning variant as uncommitted working-tree changes; if
1920            // nothing was kept, the crate is already back at its baseline. Either
1921            // way, unstage so the result is plain modifications.
1922            git::discard(&source, &crate_dir).ok();
1923            git::unstage(&source, &crate_dir).ok();
1924            if let Some(save_path) = args.save.as_deref() {
1925                let attempt_summary =
1926                    run_attempt_summary(&rep, &package, &crate_dir, &spec_path, &baseline_sha);
1927                append_attempt_summary(save_path, &attempt_summary, stdout_color, args.json)?;
1928            }
1929            let start_keep_running = args.keep_running && !was_interrupted;
1930            if args.keep_running && was_interrupted {
1931                agent_warn(
1932                    stderr_color,
1933                    "--keep-running skipped because the optimization run was interrupted",
1934                );
1935            }
1936            if start_keep_running {
1937                build_keep_running_scheduler(
1938                    &source,
1939                    &package,
1940                    &profile,
1941                    stdout_color,
1942                    stderr_color,
1943                    args.json,
1944                    &interrupted,
1945                )?;
1946            }
1947            print_final_report(&rep, was_interrupted, args.json, stdout_color, &crate_dir)?;
1948            if start_keep_running {
1949                start_keep_running_scheduler(&source, &package, &profile, &sudo, stderr_color)?;
1950            }
1951            Ok(())
1952        }
1953        Err(e) => {
1954            // Restore the crate to the last accepted state (or baseline) and
1955            // unstage, so a failed run does not leave a half-applied edit behind.
1956            git::discard(&source, &crate_dir).ok();
1957            git::unstage(&source, &crate_dir).ok();
1958            Err(e)
1959        }
1960    }
1961}
1962
1963#[allow(clippy::too_many_arguments)]
1964async fn optimize_loop(
1965    args: &OptimizeArgs,
1966    source: &Path,
1967    spec_path: &Path,
1968    spec: &spec::Spec,
1969    package: &str,
1970    profile: &str,
1971    crate_dir: &str,
1972    sudo: &sudo::Sudo,
1973    client: &reqwest::Client,
1974    models: &config::ModelRoles,
1975    baseline_sha: &str,
1976    scheds_root: Option<&Path>,
1977    attempt_memory: Option<&str>,
1978    interrupted: Arc<AtomicBool>,
1979) -> Result<OptimizeOutcome> {
1980    let stderr_color = color::Style::stderr(args.no_color);
1981    let stdout_color = color::Style::stdout(args.no_color);
1982    let coding_system = coding_system_prompt(package, crate_dir, scheds_root.is_some());
1983    let model_turn_timeout = spec.ai.turn_timeout();
1984
1985    // Round 0: baseline measurement of the current (unmodified) crate.
1986    agent_info(stderr_color, "round 0: measuring baseline...");
1987    let base = validate::run_validation(
1988        source,
1989        spec_path,
1990        sudo,
1991        args.verbose,
1992        (!args.json).then_some(stdout_color),
1993        spec.tracing.enable_tracing,
1994        &interrupted,
1995    )?;
1996    if interrupted_requested(&interrupted) {
1997        return Ok(OptimizeOutcome {
1998            report: Report {
1999                objective: format!("{} {}", base.goal, base.metric_name),
2000                goal: base.goal,
2001                metric_name: base.metric_name,
2002                start_value: None,
2003                best_value: None,
2004                best_round: None,
2005                rounds: Vec::new(),
2006                usage: usage::Usage::default(),
2007            },
2008            interrupted: true,
2009        });
2010    }
2011    if !base.is_complete() {
2012        anyhow::bail!(
2013            "baseline does not build/attach/measure (stage={}); fix the scheduler before optimizing.\nerrors: {:?}",
2014            base.stage,
2015            base.errors
2016        );
2017    }
2018    // The baseline build produced the binary, so capture its --help now: both
2019    // phases use it. The mechanism phase shows it as "already-implemented
2020    // options" to avoid re-proposing knobs; the knob phase tunes the options it
2021    // lists. --help is the single source of truth for the levers (auto-current,
2022    // no hand-maintained registry to drift).
2023    let help_text = scheduler_help_text(source, package, profile);
2024    let accept_threshold_stddev = spec.goal.accept_threshold_stddev;
2025    let planner_system = planner_system_prompt(
2026        package,
2027        crate_dir,
2028        help_text.as_deref(),
2029        scheds_root.is_some(),
2030    );
2031    let knob_inventory = knob_inventory(source, package, profile);
2032    let knob_system = knob_system_prompt(package, crate_dir, &knob_inventory);
2033    let metric_name = base.metric_name.clone();
2034    let goal = base.goal.clone();
2035    // Plain-language goal from the spec's [goal].prompt, shown to the model.
2036    let goal_description = spec.goal.prompt.clone();
2037    let mut best_value = base.median.or(base.value).unwrap();
2038    let start_value = best_value;
2039
2040    let mut rep = Report {
2041        objective: format!("{goal} {metric_name}"),
2042        goal: goal.clone(),
2043        metric_name: metric_name.clone(),
2044        start_value: Some(start_value),
2045        best_value: Some(best_value),
2046        best_round: None,
2047        rounds: Vec::new(),
2048        usage: usage::Usage::default(),
2049    };
2050    let mut last_verdict_json = serde_json::to_string(&base.raw).unwrap_or_default();
2051    agent_success(
2052        stderr_color,
2053        format!("baseline {metric_name} = {best_value:.3} (goal: {goal})"),
2054    );
2055
2056    // Token usage summed across every model call (round 0 makes none).
2057    let mut total_usage = usage::Usage::default();
2058    let mut active_model = ActiveModelPrinter::new(!args.json, stdout_color);
2059
2060    let mut run_interrupted = interrupted_requested(&interrupted);
2061    if run_interrupted {
2062        rep.usage = total_usage;
2063        return Ok(OptimizeOutcome {
2064            report: rep,
2065            interrupted: true,
2066        });
2067    }
2068
2069    // Optimization phase. The run starts in the AI-driven knob-tuning phase
2070    // (unless [ai].skip_knobs), where the model retunes one existing option per
2071    // round; it switches to the free-form code-change phase once the model emits
2072    // the completion sentinel. Both phases share the [ai].rounds budget, so the
2073    // run always ends at [ai].rounds even if the model never leaves the knob phase.
2074    let mut phase = if spec.ai.skip_knobs {
2075        Phase::Mechanism
2076    } else {
2077        Phase::Knob
2078    };
2079
2080    for round in 1..=spec.ai.rounds {
2081        if interrupted_requested(&interrupted) {
2082            run_interrupted = true;
2083            break;
2084        }
2085
2086        agent_info(stderr_color, format!("round {round}/{}", spec.ai.rounds));
2087        let crate_diff = git::diff(source, baseline_sha, crate_dir).unwrap_or_default();
2088
2089        // 1. Produce this round's edit(s) via the configured backend. Re-prompt
2090        // if the result reproduces a change we already tried, so a model that
2091        // keeps re-deriving the same edit is pushed elsewhere instead of wasting
2092        // validation runs. "Made an edit" is detected by the working tree
2093        // changing vs the accepted baseline - uniform across backends.
2094        let scheduler_cwd = source.join(crate_dir);
2095        let planning_tl = api::tool_loop(
2096            &scheduler_cwd,
2097            scheds_root,
2098            false,
2099            spec.ai.max_tool_iterations,
2100        );
2101        let edit_tl = api::tool_loop(
2102            &scheduler_cwd,
2103            scheds_root,
2104            true,
2105            spec.ai.max_tool_iterations,
2106        );
2107        let accepted_norm = normalize_diff(&crate_diff);
2108        let derive_summary = |content: &str| -> String {
2109            if content.trim().is_empty() {
2110                "(no summary)".to_string()
2111            } else {
2112                content.lines().next().unwrap_or("").trim().to_string()
2113            }
2114        };
2115        let mut note: Option<String> = None;
2116        let summary;
2117        let mut made_edit;
2118        let mut ineffective_tries = 0u32;
2119        let mut api_tries = 0u32;
2120        let mut planner_retries = 0u32;
2121        let mut api_error: Option<String> = None;
2122        let mut model_timeout_err: Option<String> = None;
2123        let mut ineffective_edit: Option<String> = None;
2124
2125        if phase == Phase::Knob {
2126            agent_info(stderr_color, format!("round {round}: knob-tuning phase"));
2127        }
2128
2129        loop {
2130            // The planner produces a read-only plan for the coder. Skip it only
2131            // when a subprocess planner IS the coder (same backend + model):
2132            // there it plans and edits in one shot inside the coding turn below.
2133            // Otherwise run a dedicated planning turn - an openai chat with
2134            // read-only tools, or a subprocess CLI in plan mode - and hand the
2135            // plan to the (possibly different) coder.
2136            let plan = if models.planner.backend.is_subprocess()
2137                && models.planner.same_endpoint(&models.coding)
2138            {
2139                None
2140            } else {
2141                let knob_phase = phase == Phase::Knob;
2142                let up = if knob_phase {
2143                    knob_planner_prompt(
2144                        &metric_name,
2145                        &goal,
2146                        goal_description.as_deref(),
2147                        Some(best_value),
2148                        &rep.rounds,
2149                        attempt_memory,
2150                        &last_verdict_json,
2151                        &crate_diff,
2152                        note.as_deref(),
2153                    )
2154                } else {
2155                    planner_prompt(
2156                        &metric_name,
2157                        &goal,
2158                        goal_description.as_deref(),
2159                        Some(best_value),
2160                        &rep.rounds,
2161                        attempt_memory,
2162                        &last_verdict_json,
2163                        &crate_diff,
2164                        None,
2165                        note.as_deref(),
2166                    )
2167                };
2168                let system = if knob_phase {
2169                    &knob_system
2170                } else {
2171                    &planner_system
2172                };
2173                active_model.use_model("planner", &models.planner);
2174                // A subprocess planner runs read-only in plan mode and returns
2175                // its plan text; an openai planner runs the read-only tool loop.
2176                let planner_result = if models.planner.backend.is_subprocess() {
2177                    agent_cli::run(
2178                        &models.planner,
2179                        agent_cli::Mode::Plan,
2180                        !args.json,
2181                        system,
2182                        &up,
2183                        &scheduler_cwd,
2184                        args.verbose,
2185                        stderr_color,
2186                        &mut total_usage,
2187                        model_turn_timeout,
2188                        interrupted.clone(),
2189                    )
2190                    .await
2191                } else {
2192                    let stream_plan = !args.json;
2193                    api::chat(
2194                        client,
2195                        &models.planner,
2196                        system,
2197                        &up,
2198                        Some(&planning_tl),
2199                        args.verbose,
2200                        stderr_color,
2201                        stream_plan,
2202                        &mut total_usage,
2203                        model_turn_timeout,
2204                        interrupted.clone(),
2205                    )
2206                    .await
2207                };
2208                match planner_result {
2209                    Ok(plan) => {
2210                        // Knob phase: the model signals it has run out of useful
2211                        // knobs to retune. Switch to the code-change phase and
2212                        // re-plan this round there.
2213                        if knob_phase && knob_phase_complete(&plan) {
2214                            agent_info(
2215                                stderr_color,
2216                                "knob phase complete (model signaled exhaustion); switching to code-change phase",
2217                            );
2218                            phase = Phase::Mechanism;
2219                            note = None;
2220                            continue;
2221                        }
2222                        if let Some(reason) = planner_plan_rejection_reason(&plan) {
2223                            const MAX_PLANNER_RECOVERY_RETRIES: u32 = 2;
2224                            if planner_retries < MAX_PLANNER_RECOVERY_RETRIES {
2225                                planner_retries += 1;
2226                                agent_warn(
2227                                    stderr_color,
2228                                    format!(
2229                                        "planner did not produce a usable plan; retrying planner turn {planner_retries}/{MAX_PLANNER_RECOVERY_RETRIES}: {reason}"
2230                                    ),
2231                                );
2232                                note = Some(planner_recovery_note(reason));
2233                                continue;
2234                            }
2235                            agent_warn(
2236                                stderr_color,
2237                                format!(
2238                                    "planner still did not produce a usable plan after retries; continuing to coding without planner output: {reason}"
2239                                ),
2240                            );
2241                            None
2242                        } else {
2243                            Some(plan)
2244                        }
2245                    }
2246                    Err(e) if interrupt::is(&e) => {
2247                        summary = "interrupted during planner turn".to_string();
2248                        made_edit = false;
2249                        break;
2250                    }
2251                    Err(e) if model_timeout::is(&e) => {
2252                        let msg = model_timeout::summary(&e);
2253                        summary = format!("planner model turn timeout: {msg}");
2254                        made_edit = false;
2255                        model_timeout_err = Some(msg);
2256                        break;
2257                    }
2258                    Err(e) if api::is_api_error(&e) => {
2259                        let msg = api::error_summary(&e);
2260                        const MAX_API_ERROR_RETRIES: u32 = 2;
2261                        if api_tries < MAX_API_ERROR_RETRIES {
2262                            api_tries += 1;
2263                            agent_warn(
2264                                stderr_color,
2265                                format!(
2266                                    "planner API error before edit; retrying round attempt {api_tries}/{MAX_API_ERROR_RETRIES}: {msg}"
2267                                ),
2268                            );
2269                            continue;
2270                        }
2271                        summary = format!("planner api error: {msg}");
2272                        made_edit = false;
2273                        api_error = Some(msg);
2274                        break;
2275                    }
2276                    Err(e) => return Err(e),
2277                }
2278            };
2279
2280            let up = if phase == Phase::Knob {
2281                knob_edit_prompt(
2282                    &metric_name,
2283                    &goal,
2284                    goal_description.as_deref(),
2285                    Some(best_value),
2286                    &rep.rounds,
2287                    attempt_memory,
2288                    &last_verdict_json,
2289                    &crate_diff,
2290                    note.as_deref(),
2291                    plan.as_deref(),
2292                    &knob_inventory,
2293                )
2294            } else {
2295                edit_prompt(
2296                    &metric_name,
2297                    &goal,
2298                    goal_description.as_deref(),
2299                    Some(best_value),
2300                    &rep.rounds,
2301                    attempt_memory,
2302                    &last_verdict_json,
2303                    &crate_diff,
2304                    None,
2305                    note.as_deref(),
2306                    plan.as_deref(),
2307                )
2308            };
2309            let coding_turn = run_coding_turn(
2310                client,
2311                models,
2312                &coding_system,
2313                &up,
2314                &scheduler_cwd,
2315                &edit_tl,
2316                args.verbose,
2317                !args.json,
2318                stderr_color,
2319                &mut active_model,
2320                &mut total_usage,
2321                model_turn_timeout,
2322                interrupted.clone(),
2323            )
2324            .await;
2325            let content = match coding_turn {
2326                Err(e) if interrupt::is(&e) => {
2327                    let d = normalize_diff(
2328                        &git::diff(source, baseline_sha, crate_dir).unwrap_or_default(),
2329                    );
2330                    summary = "interrupted during coding turn".to_string();
2331                    made_edit = d != accepted_norm;
2332                    break;
2333                }
2334                Err(e) if model_timeout::is(&e) => {
2335                    let d = normalize_diff(
2336                        &git::diff(source, baseline_sha, crate_dir).unwrap_or_default(),
2337                    );
2338                    let msg = model_timeout::summary(&e);
2339                    summary = format!("coding model turn timeout: {msg}");
2340                    made_edit = d != accepted_norm;
2341                    model_timeout_err = Some(msg);
2342                    break;
2343                }
2344                Err(e) => return Err(e),
2345                Ok(Ok(content)) => content,
2346                Ok(Err(msg)) => {
2347                    let d = normalize_diff(
2348                        &git::diff(source, baseline_sha, crate_dir).unwrap_or_default(),
2349                    );
2350                    made_edit = d != accepted_norm;
2351                    if made_edit {
2352                        agent_warn(
2353                            stderr_color,
2354                            format!("API error after edit; continuing with current diff: {msg}"),
2355                        );
2356                        summary = format!("api error after edit; validating partial edit: {msg}");
2357                    } else {
2358                        const MAX_API_ERROR_RETRIES: u32 = 2;
2359                        if api_tries < MAX_API_ERROR_RETRIES {
2360                            api_tries += 1;
2361                            agent_warn(
2362                                stderr_color,
2363                                format!(
2364                                    "API error before edit; retrying round attempt {api_tries}/{MAX_API_ERROR_RETRIES}: {msg}"
2365                                ),
2366                            );
2367                            continue;
2368                        }
2369                        summary = format!("api error: {msg}");
2370                        api_error = Some(msg);
2371                    }
2372                    break;
2373                }
2374            };
2375            let s = derive_summary(&content);
2376            let d = normalize_diff(&git::diff(source, baseline_sha, crate_dir).unwrap_or_default());
2377            made_edit = d != accepted_norm;
2378            if !made_edit {
2379                summary = s;
2380                break;
2381            }
2382            let current_round_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
2383            if let Some(reason) = ineffective_round_edit_reason(&current_round_diff) {
2384                if ineffective_tries < 2 {
2385                    ineffective_tries += 1;
2386                    agent_warn(
2387                        stderr_color,
2388                        format!(
2389                            "ineffective edit rejected; re-prompting ({ineffective_tries}/2): {reason}"
2390                        ),
2391                    );
2392                    git::discard(source, crate_dir).ok();
2393                    note = Some(ineffective_round_note(&reason));
2394                    continue;
2395                }
2396                summary = format!("ineffective edit: {s}");
2397                ineffective_edit = Some(reason);
2398                break;
2399            }
2400            // The model decides what to change; whatever it produced is taken
2401            // as-is and measured (no duplicate-detection re-prompting).
2402            summary = s;
2403            break;
2404        }
2405
2406        // Tag knob-phase rounds in the ledger so the report (and the later
2407        // mechanism phase reading the history) can tell configuration tuning
2408        // apart from code changes.
2409        let summary = if phase == Phase::Knob {
2410            format!("knob: {summary}")
2411        } else {
2412            summary
2413        };
2414
2415        // Live cumulative token usage, refreshed each round (the final total is
2416        // also printed at the bottom of the report).
2417        agent_dim(
2418            stderr_color,
2419            format!("usage so far: {}", total_usage.footer_line()),
2420        );
2421
2422        if interrupted_requested(&interrupted) {
2423            run_interrupted = true;
2424            record_interrupted_round(&mut rep, source, crate_dir, round, summary, &goal);
2425            break;
2426        }
2427
2428        if let Some(err) = model_timeout_err.take() {
2429            agent_warn(
2430                stderr_color,
2431                format!("model turn timed out; skipping round: {err}"),
2432            );
2433            if !args.json {
2434                println!(
2435                    "{}",
2436                    stdout_color.yellow(format!("round {round}: model turn timeout (skipped)"))
2437                );
2438            }
2439            record_model_timeout_round(
2440                &mut rep, source, crate_dir, round, summary, &goal, made_edit,
2441            );
2442            continue;
2443        }
2444
2445        if made_edit && ineffective_edit.is_none() {
2446            let current_round_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
2447            ineffective_edit = ineffective_round_edit_reason(&current_round_diff);
2448        }
2449
2450        if let Some(err) = api_error {
2451            agent_error(stderr_color, format!("API error; skipping round: {err}"));
2452            if !args.json {
2453                println!(
2454                    "{}",
2455                    stdout_color.red(format!("round {round}: API error (skipped)"))
2456                );
2457            }
2458            let attempt_diff = if made_edit {
2459                git::worktree_diff(source, crate_dir).unwrap_or_default()
2460            } else {
2461                String::new()
2462            };
2463            git::discard(source, crate_dir).ok();
2464            rep.rounds.push(round_record(
2465                round,
2466                summary,
2467                "api-error",
2468                None,
2469                None,
2470                false,
2471                &goal,
2472                &attempt_diff,
2473            ));
2474            // Failed/skipped rounds (build/attach/runtime/api errors, ineffective
2475            // or no edit) just move on to the next round; the run continues until
2476            // the [ai].rounds budget is reached.
2477            continue;
2478        }
2479
2480        if let Some(reason) = ineffective_edit {
2481            agent_warn(
2482                stderr_color,
2483                format!("ineffective edit; skipping round: {reason}"),
2484            );
2485            if !args.json {
2486                println!(
2487                    "{}",
2488                    stdout_color.yellow(format!(
2489                        "round {round}: (ineffective edit skipped: {reason})"
2490                    ))
2491                );
2492            }
2493            let attempt_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
2494            git::discard(source, crate_dir).ok();
2495            rep.rounds.push(round_record(
2496                round,
2497                summary,
2498                "ineffective",
2499                None,
2500                None,
2501                false,
2502                &goal,
2503                &attempt_diff,
2504            ));
2505            // Failed/skipped rounds (build/attach/runtime/api errors, ineffective
2506            // or no edit) just move on to the next round; the run continues until
2507            // the [ai].rounds budget is reached.
2508            continue;
2509        }
2510
2511        if !made_edit {
2512            agent_warn(stderr_color, "model made no edit; skipping round");
2513            if !args.json {
2514                println!(
2515                    "{}",
2516                    stdout_color.yellow(format!("round {round}: (no edit made)"))
2517                );
2518            }
2519            git::discard(source, crate_dir).ok();
2520            rep.rounds.push(round_record(
2521                round, summary, "no-edit", None, None, false, &goal, "",
2522            ));
2523            // Failed/skipped rounds (build/attach/runtime/api errors, ineffective
2524            // or no edit) just move on to the next round; the run continues until
2525            // the [ai].rounds budget is reached.
2526            continue;
2527        }
2528
2529        // Announce the change now (before the slow build/validate) so progress
2530        // is easy to follow live.
2531        if !args.json && models.coding.backend.is_subprocess() {
2532            println!("{}", stdout_color.bold(format!("round {round}: {summary}")));
2533        }
2534
2535        // 2. Build gate with a fix sub-loop.
2536        let build_err = build_with_fix_loop(
2537            args,
2538            spec.ai.build_fix_attempts,
2539            source,
2540            package,
2541            profile,
2542            crate_dir,
2543            baseline_sha,
2544            &metric_name,
2545            &goal,
2546            goal_description.as_deref(),
2547            best_value,
2548            &rep.rounds,
2549            attempt_memory,
2550            &last_verdict_json,
2551            client,
2552            models,
2553            &coding_system,
2554            &scheduler_cwd,
2555            &edit_tl,
2556            stdout_color,
2557            stderr_color,
2558            &mut active_model,
2559            &mut total_usage,
2560            model_turn_timeout,
2561            interrupted.clone(),
2562        )
2563        .await;
2564        let build_err = match build_err {
2565            Ok(build_err) => build_err,
2566            Err(e) if interrupt::is(&e) => {
2567                run_interrupted = true;
2568                record_interrupted_round(&mut rep, source, crate_dir, round, summary, &goal);
2569                break;
2570            }
2571            Err(e) if model_timeout::is(&e) => {
2572                let msg = model_timeout::summary(&e);
2573                agent_warn(
2574                    stderr_color,
2575                    format!("model turn timeout during build fix; skipping round: {msg}"),
2576                );
2577                if !args.json {
2578                    println!(
2579                        "{}",
2580                        stdout_color.yellow(format!(
2581                            "round {round}: model turn timeout during build fix (skipped)"
2582                        ))
2583                    );
2584                }
2585                record_model_timeout_round(
2586                    &mut rep, source, crate_dir, round, summary, &goal, true,
2587                );
2588                continue;
2589            }
2590            Err(e) => return Err(e),
2591        };
2592        if interrupted_requested(&interrupted) {
2593            run_interrupted = true;
2594            record_interrupted_round(&mut rep, source, crate_dir, round, summary, &goal);
2595            break;
2596        }
2597        if build_err.is_some() {
2598            agent_error(stderr_color, "build still failing; reverting round");
2599            if !args.json {
2600                println!(
2601                    "{}",
2602                    stdout_color.red(format!("  round {round} result: build failed (reverted)"))
2603                );
2604            }
2605            let attempt_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
2606            git::discard(source, crate_dir).ok();
2607            rep.rounds.push(round_record(
2608                round,
2609                summary,
2610                "build-failed",
2611                None,
2612                None,
2613                false,
2614                &goal,
2615                &attempt_diff,
2616            ));
2617            // Failed/skipped rounds (build/attach/runtime/api errors, ineffective
2618            // or no edit) just move on to the next round; the run continues until
2619            // the [ai].rounds budget is reached.
2620            continue;
2621        }
2622
2623        // 3. Validate.
2624        let verdict = validate::run_validation(
2625            source,
2626            spec_path,
2627            sudo,
2628            args.verbose,
2629            (!args.json).then_some(stdout_color),
2630            spec.tracing.enable_tracing,
2631            &interrupted,
2632        )?;
2633        let mut verdict = verdict;
2634        last_verdict_json = serde_json::to_string(&verdict.raw).unwrap_or_default();
2635        if interrupted_requested(&interrupted) {
2636            run_interrupted = true;
2637            record_interrupted_round(&mut rep, source, crate_dir, round, summary, &goal);
2638            break;
2639        }
2640        let mut runtime_fix = 0;
2641        let mut runtime_fix_build_failed = false;
2642        while should_try_runtime_fix(&verdict)
2643            && runtime_fix < spec.ai.runtime_fix_attempts
2644            && !interrupted_requested(&interrupted)
2645        {
2646            runtime_fix += 1;
2647            agent_warn(
2648                stderr_color,
2649                format!(
2650                    "validation stage={}; runtime-fix attempt {runtime_fix}/{}",
2651                    verdict.stage, spec.ai.runtime_fix_attempts
2652                ),
2653            );
2654            let diff_now = git::diff(source, baseline_sha, crate_dir).unwrap_or_default();
2655            let feedback = validation_failure_feedback(&verdict);
2656            let up = edit_prompt(
2657                &metric_name,
2658                &goal,
2659                goal_description.as_deref(),
2660                Some(best_value),
2661                &rep.rounds,
2662                attempt_memory,
2663                &last_verdict_json,
2664                &diff_now,
2665                Some(&feedback),
2666                None,
2667                None,
2668            );
2669            let before_fix = normalize_diff(&diff_now);
2670            let fix_turn = run_coding_turn(
2671                client,
2672                models,
2673                &coding_system,
2674                &up,
2675                &scheduler_cwd,
2676                &edit_tl,
2677                args.verbose,
2678                !args.json,
2679                stderr_color,
2680                &mut active_model,
2681                &mut total_usage,
2682                model_turn_timeout,
2683                interrupted.clone(),
2684            )
2685            .await;
2686            let fix_attempted = match fix_turn {
2687                Err(e) if interrupt::is(&e) => {
2688                    run_interrupted = true;
2689                    break;
2690                }
2691                Err(e) if model_timeout::is(&e) => {
2692                    model_timeout_err = Some(model_timeout::summary(&e));
2693                    break;
2694                }
2695                Err(e) => return Err(e),
2696                Ok(Ok(_)) => true,
2697                Ok(Err(msg)) => {
2698                    let after_fix = normalize_diff(
2699                        &git::diff(source, baseline_sha, crate_dir).unwrap_or_default(),
2700                    );
2701                    if after_fix != before_fix {
2702                        agent_warn(
2703                            stderr_color,
2704                            format!("API error after runtime-fix edit; retrying validation: {msg}"),
2705                        );
2706                        true
2707                    } else {
2708                        agent_error(
2709                            stderr_color,
2710                            format!("API error during runtime-fix attempt; reverting round: {msg}"),
2711                        );
2712                        false
2713                    }
2714                }
2715            };
2716            if !fix_attempted {
2717                break;
2718            }
2719
2720            let build_err = build_with_fix_loop(
2721                args,
2722                spec.ai.build_fix_attempts,
2723                source,
2724                package,
2725                profile,
2726                crate_dir,
2727                baseline_sha,
2728                &metric_name,
2729                &goal,
2730                goal_description.as_deref(),
2731                best_value,
2732                &rep.rounds,
2733                attempt_memory,
2734                &last_verdict_json,
2735                client,
2736                models,
2737                &coding_system,
2738                &scheduler_cwd,
2739                &edit_tl,
2740                stdout_color,
2741                stderr_color,
2742                &mut active_model,
2743                &mut total_usage,
2744                model_turn_timeout,
2745                interrupted.clone(),
2746            )
2747            .await;
2748            let build_err = match build_err {
2749                Ok(build_err) => build_err,
2750                Err(e) if interrupt::is(&e) => {
2751                    run_interrupted = true;
2752                    break;
2753                }
2754                Err(e) if model_timeout::is(&e) => {
2755                    model_timeout_err = Some(model_timeout::summary(&e));
2756                    break;
2757                }
2758                Err(e) => return Err(e),
2759            };
2760            if build_err.is_some() {
2761                runtime_fix_build_failed = true;
2762                break;
2763            }
2764
2765            verdict = validate::run_validation(
2766                source,
2767                spec_path,
2768                sudo,
2769                args.verbose,
2770                (!args.json).then_some(stdout_color),
2771                spec.tracing.enable_tracing,
2772                &interrupted,
2773            )?;
2774            last_verdict_json = serde_json::to_string(&verdict.raw).unwrap_or_default();
2775        }
2776        if interrupted_requested(&interrupted) {
2777            run_interrupted = true;
2778            record_interrupted_round(&mut rep, source, crate_dir, round, summary, &goal);
2779            break;
2780        }
2781        if let Some(err) = model_timeout_err.take() {
2782            agent_warn(
2783                stderr_color,
2784                format!("model turn timeout during runtime fix; skipping round: {err}"),
2785            );
2786            if !args.json {
2787                println!(
2788                    "{}",
2789                    stdout_color.yellow(format!(
2790                        "round {round}: model turn timeout during runtime fix (skipped)"
2791                    ))
2792                );
2793            }
2794            record_model_timeout_round(&mut rep, source, crate_dir, round, summary, &goal, true);
2795            continue;
2796        }
2797        if runtime_fix_build_failed {
2798            agent_error(
2799                stderr_color,
2800                "build failed while fixing runtime failure; reverting round",
2801            );
2802            if !args.json {
2803                println!(
2804                    "{}",
2805                    stdout_color.red(format!("  round {round} result: build failed (reverted)"))
2806                );
2807            }
2808            let attempt_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
2809            git::discard(source, crate_dir).ok();
2810            rep.rounds.push(round_record(
2811                round,
2812                summary,
2813                "build-failed",
2814                None,
2815                None,
2816                false,
2817                &goal,
2818                &attempt_diff,
2819            ));
2820            // Failed/skipped rounds (build/attach/runtime/api errors, ineffective
2821            // or no edit) just move on to the next round; the run continues until
2822            // the [ai].rounds budget is reached.
2823            continue;
2824        }
2825        if !verdict.is_complete() {
2826            let outcome_label = match verdict.stage.as_str() {
2827                "build" => "build-failed",
2828                "attach" | "preflight" => "attach-failed",
2829                "runtime" | "metric" => "runtime-failed",
2830                _ => "metric-failed",
2831            };
2832            agent_error(
2833                stderr_color,
2834                format!("validation stage={} -> reverting", verdict.stage),
2835            );
2836            let attempt_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
2837            git::discard(source, crate_dir).ok();
2838            rep.rounds.push(round_record(
2839                round,
2840                summary,
2841                outcome_label,
2842                None,
2843                None,
2844                false,
2845                &goal,
2846                &attempt_diff,
2847            ));
2848            // Failed/skipped rounds (build/attach/runtime/api errors, ineffective
2849            // or no edit) just move on to the next round; the run continues until
2850            // the [ai].rounds budget is reached.
2851            continue;
2852        }
2853
2854        // 4. Keep/revert decision.
2855        let value = verdict.median.or(verdict.value).unwrap();
2856        let stddev = verdict.stddev.unwrap_or(0.0);
2857        let margin = accept_threshold_stddev * stddev;
2858        let improved = if goal == "minimize" {
2859            value < best_value - margin
2860        } else {
2861            value > best_value + margin
2862        };
2863        let delta = value - best_value;
2864
2865        let attempt_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
2866        if improved {
2867            git::checkpoint(source, crate_dir).with_context(|| "stage accepted variant")?;
2868            best_value = value;
2869            rep.best_value = Some(best_value);
2870            rep.best_round = Some(round);
2871            rep.rounds.push(round_record(
2872                round,
2873                summary,
2874                "kept",
2875                Some(value),
2876                Some(delta),
2877                true,
2878                &goal,
2879                &attempt_diff,
2880            ));
2881            agent_success(
2882                stderr_color,
2883                format!("KEPT: {metric_name} = {value:.3} (Δ {delta:+.3})"),
2884            );
2885        } else {
2886            git::discard(source, crate_dir).ok();
2887            rep.rounds.push(round_record(
2888                round,
2889                summary,
2890                "reverted",
2891                Some(value),
2892                Some(delta),
2893                false,
2894                &goal,
2895                &attempt_diff,
2896            ));
2897            agent_warn(
2898                stderr_color,
2899                format!("reverted: {metric_name} = {value:.3} (Δ {delta:+.3}, margin {margin:.3})"),
2900            );
2901        }
2902    }
2903
2904    if interrupted_requested(&interrupted) {
2905        run_interrupted = true;
2906    }
2907    rep.usage = total_usage;
2908    Ok(OptimizeOutcome {
2909        report: rep,
2910        interrupted: run_interrupted,
2911    })
2912}
2913
2914#[cfg(test)]
2915mod tests {
2916    use super::*;
2917
2918    #[test]
2919    fn parses_spec_toml_alias() {
2920        let args = OptimizeArgs::try_parse_from([
2921            "scx-forge-agent",
2922            "--spec-toml",
2923            "/tmp/custom.spec.toml",
2924        ])
2925        .unwrap();
2926
2927        assert_eq!(
2928            args.spec.as_deref(),
2929            Some(Path::new("/tmp/custom.spec.toml"))
2930        );
2931    }
2932
2933    #[test]
2934    fn parses_save_and_resume() {
2935        let args = OptimizeArgs::try_parse_from([
2936            "scx-forge-agent",
2937            "--save",
2938            "/tmp/save.md",
2939            "--resume",
2940            "/tmp/resume.md",
2941        ])
2942        .unwrap();
2943
2944        assert_eq!(args.save.as_deref(), Some(Path::new("/tmp/save.md")));
2945        assert_eq!(args.resume.as_deref(), Some(Path::new("/tmp/resume.md")));
2946    }
2947
2948    #[test]
2949    fn parses_keep_running() {
2950        let args = OptimizeArgs::try_parse_from(["scx-forge-agent", "--keep-running"]).unwrap();
2951
2952        assert!(args.keep_running);
2953    }
2954
2955    #[test]
2956    fn expands_tilde_in_sudo_passwd_file() {
2957        let home = std::env::temp_dir().join(format!("scx_home_test_{}", std::process::id()));
2958        std::fs::create_dir_all(&home).unwrap();
2959
2960        let path = expand_sudo_password_file_in(
2961            Path::new("/tmp/spec.toml"),
2962            Path::new("~/.pass"),
2963            Some(home.clone()),
2964        )
2965        .unwrap();
2966        assert_eq!(path, home.join(".pass"));
2967
2968        let _ = std::fs::remove_dir_all(&home);
2969    }
2970
2971    #[test]
2972    fn expands_relative_wildcard_in_sudo_passwd_file() {
2973        let base = std::env::temp_dir().join(format!("scx_glob_test_{}", std::process::id()));
2974        std::fs::create_dir_all(base.join("secrets")).unwrap();
2975        let pass = base.join("secrets/sudo-pass");
2976        std::fs::write(&pass, "pw\n").unwrap();
2977
2978        let path = expand_sudo_password_file(&base.join("spec.toml"), Path::new("secrets/sudo-*"))
2979            .unwrap();
2980        assert_eq!(path, pass);
2981
2982        let _ = std::fs::remove_dir_all(&base);
2983    }
2984
2985    #[test]
2986    fn rejects_ambiguous_sudo_passwd_file_wildcard() {
2987        let base =
2988            std::env::temp_dir().join(format!("scx_glob_ambiguous_test_{}", std::process::id()));
2989        std::fs::create_dir_all(base.join("secrets")).unwrap();
2990        std::fs::write(base.join("secrets/a.pass"), "pw\n").unwrap();
2991        std::fs::write(base.join("secrets/b.pass"), "pw\n").unwrap();
2992
2993        let err = expand_sudo_password_file(&base.join("spec.toml"), Path::new("secrets/*.pass"))
2994            .unwrap_err()
2995            .to_string();
2996        assert!(err.contains("ambiguous"), "got: {err}");
2997
2998        let _ = std::fs::remove_dir_all(&base);
2999    }
3000
3001    #[test]
3002    fn optimizer_mission_is_neutral_and_knob_aware() {
3003        let optimizer_md = OPTIMIZER_MD
3004            .split_whitespace()
3005            .collect::<Vec<_>>()
3006            .join(" ");
3007        let has = |phrase: &str| {
3008            optimizer_md.contains(&phrase.split_whitespace().collect::<Vec<_>>().join(" "))
3009        };
3010
3011        // The static mission derives logic from observed runtime properties and
3012        // stays neutral about other schedulers: the cross-scheduler porting
3013        // guidance and tool names are injected by planner_system_prompt only when
3014        // [ai].cross_scheduler_refs is enabled, never baked into this resource.
3015        assert!(has("wakeup frequency"));
3016        assert!(!has("explore the other schedulers"));
3017        assert!(!has("read_scheduler_file"));
3018        assert!(!has("list_schedulers"));
3019        // The knob space is off-limits, stated generically against whatever
3020        // options the scheduler exposes - not a hardcoded list of scx_forge enums.
3021        assert!(has("Do not re-implement an existing knob"));
3022        assert!(has("changes the default value of an existing option"));
3023        assert!(!has("DSQ topology"));
3024        assert!(!has("idle-CPU policy"));
3025    }
3026
3027    #[test]
3028    fn coder_prompt_is_just_the_plan() {
3029        let plan = "Summary: add wakeup-frequency-weighted deadlines.\nEdit task_dsq_key() in src/bpf/main.bpf.c.";
3030        let prompt = edit_prompt(
3031            "latency",
3032            "minimize",
3033            Some("reduce benchmark latency"),
3034            Some(10.0),
3035            &[],
3036            None,
3037            "{}",
3038            "",
3039            None,
3040            None,
3041            Some(plan),
3042        );
3043        // The coder sees only the plan plus a short apply instruction - none of
3044        // the round context (history, verdict, diff) leaks in.
3045        assert!(prompt.contains(plan));
3046        assert!(prompt.contains("Implement this experiment with the edit_file tool"));
3047        assert!(!prompt.contains("Optimization history"));
3048        assert!(!prompt.contains("Last benchmark result"));
3049        assert!(!prompt.contains("Currently applied changes"));
3050    }
3051
3052    #[test]
3053    fn planner_prompt_does_not_request_edits() {
3054        let prompt = planner_prompt(
3055            "latency",
3056            "minimize",
3057            Some("reduce benchmark latency"),
3058            Some(10.0),
3059            &[],
3060            None,
3061            "{}",
3062            "",
3063            None,
3064            None,
3065        );
3066
3067        assert!(prompt.contains("Return the experiment plan now"));
3068        assert!(prompt.contains("do not edit files"));
3069        assert!(!prompt.contains("Implement this experiment with the edit_file tool"));
3070    }
3071
3072    #[test]
3073    fn planner_plan_rejection_catches_empty_and_tool_errors() {
3074        assert_eq!(
3075            planner_plan_rejection_reason("   "),
3076            Some("empty planner response")
3077        );
3078        assert_eq!(
3079            planner_plan_rejection_reason("ERROR: unknown tool: str_replace_editor"),
3080            Some("planner returned a tool error instead of a plan")
3081        );
3082        assert_eq!(
3083            planner_plan_rejection_reason(
3084                "Summary: switch enqueue to a per-LLC DSQ and update dispatch to pull from the same LLC queue."
3085            ),
3086            None
3087        );
3088    }
3089
3090    #[test]
3091    fn planner_system_prompt_is_planner_only() {
3092        // Default (cross-scheduler refs disabled): planner-only, crate-focused,
3093        // with no mention of other schedulers.
3094        let prompt = planner_system_prompt("scx_forge", "scheds/rust/scx_forge", None, false);
3095        assert!(prompt.contains("You are the PLANNER"));
3096        assert!(prompt.contains("Do not edit files"));
3097        assert!(!prompt.contains("scheds/rust`"));
3098        assert!(!prompt.contains("list_schedulers"));
3099
3100        // With refs enabled: the cross-scheduler porting guidance and tools are
3101        // advertised.
3102        let refs = planner_system_prompt("scx_forge", "scheds/rust/scx_forge", None, true);
3103        assert!(refs.contains("You are the PLANNER"));
3104        assert!(refs.contains("cross-scheduler reference tools are enabled"));
3105        assert!(refs.contains("read_scheduler_file"));
3106    }
3107
3108    #[test]
3109    fn planner_system_prompt_lists_existing_knobs_from_help() {
3110        let help = "Options:\n  --dsq-topology <V>  topology\n  --ordering <V>  ordering";
3111        let prompt = planner_system_prompt("scx_forge", "scheds/rust/scx_forge", Some(help), false);
3112        assert!(prompt.contains("## Already-implemented options (do NOT re-propose these)"));
3113        assert!(prompt.contains("--dsq-topology"));
3114        assert!(prompt.contains("--ordering"));
3115        // Without help text, that injected section is omitted (the mission text
3116        // mentions the phrase in prose, so match the full injected header).
3117        let bare = planner_system_prompt("scx_forge", "scheds/rust/scx_forge", None, false);
3118        assert!(!bare.contains("## Already-implemented options (do NOT re-propose these)"));
3119    }
3120
3121    #[test]
3122    fn prompt_includes_previous_attempt_memory() {
3123        let prompt = planner_prompt(
3124            "score",
3125            "maximize",
3126            Some("increase throughput"),
3127            Some(10.0),
3128            &[],
3129            Some("round 1 | reverted | bad placement idea"),
3130            "{}",
3131            "",
3132            None,
3133            None,
3134        );
3135
3136        assert!(prompt.contains("Previous run attempt memory"));
3137        assert!(prompt.contains("bad placement idea"));
3138    }
3139
3140    #[test]
3141    fn run_attempt_summary_records_rounds() {
3142        let mut rep = Report {
3143            objective: "maximize score".into(),
3144            goal: "maximize".into(),
3145            metric_name: "score".into(),
3146            start_value: Some(10.0),
3147            best_value: Some(12.0),
3148            best_round: Some(1),
3149            rounds: Vec::new(),
3150            usage: usage::Usage::default(),
3151        };
3152        rep.rounds.push(round_record(
3153            1,
3154            "Bias wakeup CPU placement".into(),
3155            "kept",
3156            Some(12.0),
3157            Some(2.0),
3158            true,
3159            "maximize",
3160            "select_cpu",
3161        ));
3162
3163        let summary = run_attempt_summary(
3164            &rep,
3165            "scx_forge",
3166            "scheds/rust/scx_forge",
3167            Path::new("spec.toml"),
3168            "abc123",
3169        );
3170
3171        assert!(summary.contains("Package: `scx_forge`"));
3172        assert!(summary.contains("| 1 | kept | 12.000 | +2.000 |"));
3173        assert!(summary.contains("Bias wakeup CPU placement"));
3174    }
3175
3176    #[test]
3177    fn prompts_keep_history_neutral_after_failed_rounds() {
3178        let history = vec![round_record(
3179            1,
3180            "reduced timeslice by 10%".into(),
3181            "reverted",
3182            Some(11.0),
3183            Some(1.0),
3184            false,
3185            "minimize",
3186            "",
3187        )];
3188        let prompt = edit_prompt(
3189            "latency",
3190            "minimize",
3191            Some("reduce benchmark latency"),
3192            Some(10.0),
3193            &history,
3194            None,
3195            "{}",
3196            "",
3197            None,
3198            None,
3199            None,
3200        );
3201
3202        assert!(prompt.contains("Optimization history"));
3203        // History is shown as neutral factual context, with no directive to
3204        // exploit kept results or ban reverted areas.
3205        assert!(!prompt.contains("Cooldown"));
3206        assert!(!prompt.contains("DIFFERENT policy seam"));
3207        assert!(!prompt.contains("do not answer with another timeslice/constant adjustment"));
3208        assert!(!prompt.contains("compose and adapt a reusable structural policy concept"));
3209    }
3210
3211    #[test]
3212    fn knob_phase_complete_detects_sentinel_only_as_full_line() {
3213        // The sentinel ends the knob phase only when it stands on its own line.
3214        assert!(knob_phase_complete("KNOB_PHASE_COMPLETE"));
3215        assert!(knob_phase_complete("done tuning\nKNOB_PHASE_COMPLETE\n"));
3216        assert!(knob_phase_complete("  KNOB_PHASE_COMPLETE  "));
3217        // A normal plan that merely mentions it is not a completion signal.
3218        assert!(!knob_phase_complete(
3219            "Set ordering default to Deadline; this is not KNOB_PHASE_COMPLETE yet"
3220        ));
3221        assert!(!knob_phase_complete("Set idle_policy default to Waker."));
3222    }
3223
3224    #[test]
3225    fn knob_system_prompt_constrains_to_clap_defaults_and_lists_options() {
3226        let inventory = "## Command-line options (from --help)\n```\n--ordering <ORDERING>  [possible values: vruntime, deadline, fifo]\n```\n";
3227        let prompt = knob_system_prompt("scx_forge", "scheds/rust/scx_forge", inventory);
3228        let prompt_words = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
3229        // It is the planner, scoped to retuning existing clap defaults.
3230        assert!(prompt.contains("KNOB-TUNING phase"));
3231        assert!(prompt.contains("Add or change only"));
3232        assert!(prompt.contains("default_value_t"));
3233        assert!(prompt.contains("optional string options"));
3234        assert!(prompt.contains("to_bpf()"));
3235        assert!(prompt.contains("KNOB_PHASE_COMPLETE"));
3236        assert!(prompt.contains("Bias knob selection by workload saturation"));
3237        assert!(prompt.contains("placement-style knobs"));
3238        assert!(prompt.contains("ordering-style knobs"));
3239        assert!(prompt_words.contains("saturated, unsaturated, or mixed"));
3240        assert!(prompt_words.contains("CPU capacity, fast/slow CPU preference"));
3241        assert!(prompt_words.contains("primary / performance domains"));
3242        assert!(!KNOB_MD.contains("KNOB_PHASE"));
3243        // The discovered options are embedded so the model knows what it can tune.
3244        assert!(prompt.contains("possible values: vruntime, deadline, fifo"));
3245    }
3246
3247    #[test]
3248    fn knob_edit_prompt_carries_inventory_only_without_a_plan() {
3249        let inventory = "OPTION_INVENTORY_MARKER";
3250        // With a plan (openai backend) the coder just applies it; the inventory
3251        // already reached the planner, so it is not repeated here.
3252        let with_plan = knob_edit_prompt(
3253            "latency",
3254            "minimize",
3255            None,
3256            Some(10.0),
3257            &[],
3258            None,
3259            "{}",
3260            "",
3261            None,
3262            Some("Set ordering default to Deadline"),
3263            inventory,
3264        );
3265        assert!(with_plan.contains("Set ordering default to Deadline"));
3266        assert!(with_plan.contains("to_bpf()"));
3267        assert!(!with_plan.contains(inventory));
3268        // Without a plan (subprocess backend) the coder must see the inventory to
3269        // pick a knob itself.
3270        let no_plan = knob_edit_prompt(
3271            "latency",
3272            "minimize",
3273            None,
3274            Some(10.0),
3275            &[],
3276            None,
3277            "{}",
3278            "",
3279            None,
3280            None,
3281            inventory,
3282        );
3283        assert!(no_plan.contains(inventory));
3284    }
3285
3286    #[test]
3287    fn classify_attempt_tags_policy_area_and_direction() {
3288        let tags = classify_attempt(
3289            "Bias wakeup CPU placement toward idle siblings",
3290            "diff --git a/src/bpf/main.bpf.c b/src/bpf/main.bpf.c\n+select_cpu_idle_sibling();",
3291        );
3292
3293        assert_eq!(tags.policy_area, "placement");
3294        assert!(tags.direction.contains("bias"));
3295        assert!(tags.direction.contains("wakeup"));
3296    }
3297
3298    #[test]
3299    fn detects_bpf_const_volatile_initializer_only_diff() {
3300        let diff = r#"diff --git a/scheds/rust/scx_forge/src/bpf/main.bpf.c b/scheds/rust/scx_forge/src/bpf/main.bpf.c
3301index 1d6827ca2..179c31d0d 100644
3302--- a/scheds/rust/scx_forge/src/bpf/main.bpf.c
3303+++ b/scheds/rust/scx_forge/src/bpf/main.bpf.c
3304@@ -48,7 +48,7 @@ const volatile bool debug;
3305 /*
3306  * Default task time slice.
3307  */
3308-const volatile u64 slice_ns = NSEC_PER_MSEC;
3309+const volatile u64 slice_ns = NSEC_PER_MSEC / 2;
3310 
3311 /*
3312  * SMT (Simultaneous Multi-Threading) is enabled on the system.
3313"#;
3314
3315        let reason = ineffective_round_edit_reason(diff).unwrap();
3316        assert!(reason.contains("slice_ns"));
3317        assert!(reason.contains("Rust rodata"));
3318    }
3319
3320    #[test]
3321    fn allows_bpf_const_volatile_diff_when_rust_is_changed_too() {
3322        let diff = r#"diff --git a/scheds/rust/scx_forge/src/bpf/main.bpf.c b/scheds/rust/scx_forge/src/bpf/main.bpf.c
3323--- a/scheds/rust/scx_forge/src/bpf/main.bpf.c
3324+++ b/scheds/rust/scx_forge/src/bpf/main.bpf.c
3325@@ -48,7 +48,7 @@
3326-const volatile u64 slice_ns = NSEC_PER_MSEC;
3327+const volatile u64 slice_ns = NSEC_PER_MSEC / 2;
3328diff --git a/scheds/rust/scx_forge/src/main.rs b/scheds/rust/scx_forge/src/main.rs
3329--- a/scheds/rust/scx_forge/src/main.rs
3330+++ b/scheds/rust/scx_forge/src/main.rs
3331@@ -164,7 +164,7 @@
3332-        rodata.slice_ns = opts.slice_us * 1000;
3333+        rodata.slice_ns = opts.slice_us * 500;
3334"#;
3335
3336        assert!(ineffective_round_edit_reason(diff).is_none());
3337    }
3338
3339    #[test]
3340    fn allows_bpf_policy_logic_diff() {
3341        let diff = r#"diff --git a/scheds/rust/scx_forge/src/bpf/main.bpf.c b/scheds/rust/scx_forge/src/bpf/main.bpf.c
3342--- a/scheds/rust/scx_forge/src/bpf/main.bpf.c
3343+++ b/scheds/rust/scx_forge/src/bpf/main.bpf.c
3344@@ -342,7 +342,7 @@
3345-        return slice_ns;
3346+        return slice_ns / 2;
3347"#;
3348
3349        assert!(ineffective_round_edit_reason(diff).is_none());
3350    }
3351}