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