use std::path::{Path, PathBuf};
use candle_core::Result as CResult;
use crate::claude_backend::ClaudeBackend;
use crate::inference::InferencePipeline;
use crate::poly_jit::{PolyJitEngine, PolyJitRunRequest};
use crate::self_heal::{run_supervised, SupervisorOptions};
#[derive(Debug, Clone, Default)]
pub struct SolveOptions {
pub max_rounds: usize,
pub max_restarts: usize,
pub heal_memory_path: Option<PathBuf>,
pub anchor: Option<PathBuf>,
pub source_path: Option<PathBuf>,
pub patch_memory_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Default)]
pub struct SolveReport {
pub solved: bool,
pub rounds: usize,
pub env_heals: Vec<String>,
pub source_patched: bool,
pub llm_patched: bool,
pub fleet_patched: bool,
pub diagnostics: Vec<String>,
pub final_exit: Option<i32>,
pub tokens_absorbed: usize,
}
pub fn solve(
pipeline: &InferencePipeline,
command: &str,
args: &[String],
opts: &SolveOptions,
) -> CResult<SolveReport> {
let mut report = SolveReport::default();
let max_rounds = opts.max_rounds.max(1);
for round in 1..=max_rounds {
report.rounds = round;
let sup = run_supervised(
pipeline,
command,
args,
&SupervisorOptions {
max_restarts: opts.max_restarts,
heal_memory_path: opts.heal_memory_path.clone(),
anchor: opts.anchor.clone(),
..SupervisorOptions::default()
},
)?;
report.final_exit = sup.exit_code;
report.tokens_absorbed += sup.tokens_absorbed;
let round_heals = sup.heals.len();
for h in &sup.heals {
report.env_heals.push(h.to_string());
}
for d in &sup.diagnostics {
if !report.diagnostics.contains(d) {
report.diagnostics.push(d.clone());
}
}
if sup.success {
report.solved = true;
println!("[axiom-solve] round {round}: environment heal solved it");
return Ok(report);
}
let candidates: Vec<PathBuf> = if let Some(s) = opts.source_path.as_deref() {
vec![s.to_path_buf()]
} else {
let working_dir = opts.anchor.as_deref();
let (_still_failing, trace) = run_verify_capture(command, args, working_dir);
let root = opts
.anchor
.clone()
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."));
let cands = crate::fault_locate::candidate_sources(&trace, &root);
if let Some(first) = cands.first() {
println!(
"[axiom-solve] round {round}: localized {} candidate file(s); first: {}",
cands.len(),
first.display()
);
}
cands
};
for src in &candidates {
if attempt_source_repair(command, args, opts, round, &sup.diagnostics, src, &mut report)?
{
return Ok(report);
}
}
if round_heals == 0 {
println!("[axiom-solve] round {round}: no progress — stopping");
break;
}
println!("[axiom-solve] round {round}: environment advanced, continuing");
}
Ok(report)
}
#[allow(clippy::too_many_arguments)]
fn attempt_source_repair(
command: &str,
args: &[String],
opts: &SolveOptions,
round: usize,
env_diagnostics: &[String],
src: &Path,
report: &mut SolveReport,
) -> CResult<bool> {
let rel = rel_path_for(src, opts.anchor.as_deref());
let backup = std::fs::read_to_string(src).ok();
let engine = PolyJitEngine::default();
let run_req = PolyJitRunRequest {
session_id: format!("solve-{round}"),
command: command.to_string(),
args: args.to_vec(),
working_dir: opts.anchor.as_ref().map(|p| p.display().to_string()),
source_path: Some(src.display().to_string()),
};
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| candle_core::Error::Msg(format!("solve runtime: {e}")))?;
let poly = rt
.block_on(engine.run_with_feedback(run_req, |_diag| async { Ok(()) }))
.map_err(|e| candle_core::Error::Msg(format!("poly-jit: {e}")))?;
if poly.passed {
report.source_patched = poly.patched;
report.solved = true;
report.final_exit = Some(0);
println!(
"[axiom-solve] round {round}: source repair solved it (patched={})",
poly.patched
);
return Ok(true);
}
if let Some(b) = backup {
let _ = std::fs::write(src, b);
}
if let Some(pm_path) = opts.patch_memory_path.as_deref() {
let fp = crate::heal_memory::fingerprint(command, args);
let pm = crate::patch_memory::PatchMemory::load(pm_path);
let working_dir = opts.anchor.as_deref();
if let Some(sha) =
pm.try_candidates(&fp, &rel, src, || run_verify(command, args, working_dir))
{
report.fleet_patched = true;
report.solved = true;
report.final_exit = Some(0);
println!(
"[axiom-solve] round {round}: fleet patch {} verified green locally",
&sha[..sha.len().min(12)]
);
return Ok(true);
}
}
if let Some(backend) = ClaudeBackend::from_env() {
let working_dir = opts.anchor.as_deref();
let (_still_failing, trace) = run_verify_capture(command, args, working_dir);
let failure_output = if trace.trim().is_empty() {
env_diagnostics.join("\n")
} else {
trace
};
let solved = llm_repair_round(&backend, command, args, working_dir, src, &failure_output);
if solved {
report.llm_patched = true;
report.solved = true;
report.final_exit = Some(0);
println!("[axiom-solve] round {round}: LLM-proposed patch verified green");
if let Some(pm_path) = opts.patch_memory_path.as_deref() {
if let Ok(content) = std::fs::read_to_string(src) {
let fp = crate::heal_memory::fingerprint(command, args);
let mut pm = crate::patch_memory::PatchMemory::load(pm_path);
pm.record_verified(&fp, &rel, &content);
let _ = pm.save(pm_path);
}
}
return Ok(true);
}
}
Ok(false)
}
fn llm_repair_attempts() -> usize {
std::env::var("AXIOM_SOLVE_LLM_ATTEMPTS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or(3)
}
fn fault_hint_from(failure: &str, source_path: &Path) -> String {
let lines = crate::fault_locate::line_hints_for(failure, source_path);
if lines.is_empty() {
return String::new();
}
let shown: Vec<String> = lines.iter().take(5).map(|l| l.to_string()).collect();
format!(
"The failure points at {} line(s): {}. Focus the fix there.\n\n",
source_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
shown.join(", "),
)
}
fn llm_repair_round(
backend: &ClaudeBackend,
command: &str,
args: &[String],
working_dir: Option<&Path>,
source_path: &Path,
failure_output: &str,
) -> bool {
let max_attempts = llm_repair_attempts();
apply_verified_patch_iterative(
command,
args,
working_dir,
source_path,
max_attempts,
failure_output,
|failure, original| {
let fault_hint = fault_hint_from(failure, source_path);
let prompt = format!(
"A verification command is failing and you must fix the source so it passes.\n\n\
Command: {command} {args}\n\n\
Failure output:\n{failure}\n\n\
{fault_hint}\
Current contents of {path}:\n```\n{original}\n```\n\n\
Return ONLY the complete corrected file contents — no explanation, no commentary, \
no markdown code fences.",
args = args.join(" "),
path = source_path.display(),
);
match backend.generate(&prompt, 8192) {
Ok(text) => {
let candidate = strip_code_fences(&text);
if original.len() > 1024 && candidate.len() < original.len() / 2 {
eprintln!(
"[axiom-solve] LLM repair skipped (candidate {} B vs original {} B — likely truncated)",
candidate.len(),
original.len()
);
None
} else {
Some(candidate)
}
}
Err(e) => {
eprintln!("[axiom-solve] LLM repair skipped (backend error: {e})");
None
}
}
},
)
}
fn apply_verified_patch_iterative<P>(
command: &str,
args: &[String],
working_dir: Option<&Path>,
source_path: &Path,
max_attempts: usize,
initial_failure: &str,
mut propose: P,
) -> bool
where
P: FnMut(&str, &str) -> Option<String>,
{
let Ok(original) = std::fs::read_to_string(source_path) else {
return false;
};
let mut failure = initial_failure.to_string();
for attempt in 1..=max_attempts.max(1) {
let Some(candidate) = propose(&failure, &original) else {
break; };
if candidate.trim().is_empty() || candidate == original {
break;
}
if let Err(e) = std::fs::write(source_path, &candidate) {
eprintln!("[axiom-solve] patch write failed; restoring original ({e})");
let _ = std::fs::write(source_path, &original);
break;
}
let (green, trace) = run_verify_capture(command, args, working_dir);
if green {
if attempt > 1 {
eprintln!("[axiom-solve] LLM repair verified green on attempt {attempt}");
}
return true;
}
if let Err(e) = std::fs::write(source_path, &original) {
eprintln!("[axiom-solve] WARNING: failed to restore original after rejected patch: {e}");
}
if !trace.trim().is_empty() {
failure = trace;
}
}
false
}
fn verify_timeout() -> std::time::Duration {
let secs = std::env::var("AXIOM_SOLVE_VERIFY_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|&s| s > 0)
.unwrap_or(120);
std::time::Duration::from_secs(secs)
}
fn rel_path_for(src: &Path, anchor: Option<&Path>) -> String {
if let Some(a) = anchor {
if let Ok(stripped) = src.strip_prefix(a) {
let s = stripped.to_string_lossy();
if !s.is_empty() {
return s.into_owned();
}
}
}
src.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| src.display().to_string())
}
pub fn posix_shell() -> String {
if cfg!(windows) {
let candidates = [
r"C:\Program Files\Git\bin\sh.exe",
r"C:\Program Files\Git\usr\bin\sh.exe",
r"C:\Program Files (x86)\Git\bin\sh.exe",
r"C:\Program Files (x86)\Git\usr\bin\sh.exe",
];
for c in candidates {
if std::path::Path::new(c).exists() {
return c.to_string();
}
}
}
"sh".to_string()
}
pub fn run_verify(command: &str, args: &[String], working_dir: Option<&Path>) -> bool {
run_verify_capture(command, args, working_dir).0
}
pub fn run_verify_capture(
command: &str,
args: &[String],
working_dir: Option<&Path>,
) -> (bool, String) {
use std::io::Read;
use std::process::Stdio;
use std::time::Instant;
let mut cmd = std::process::Command::new(command);
cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return (false, format!("failed to spawn verify command: {e}")),
};
let mut stdout = child.stdout.take();
let mut stderr = child.stderr.take();
let out_h = std::thread::spawn(move || {
let mut s = String::new();
if let Some(ref mut o) = stdout {
let _ = o.read_to_string(&mut s);
}
s
});
let err_h = std::thread::spawn(move || {
let mut s = String::new();
if let Some(ref mut e) = stderr {
let _ = e.read_to_string(&mut s);
}
s
});
let deadline = Instant::now() + verify_timeout();
let mut timed_out = false;
let status = loop {
match child.try_wait() {
Ok(Some(st)) => break Some(st),
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
timed_out = true;
break None;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(_) => break None,
}
};
let mut trace = out_h.join().unwrap_or_default();
trace.push_str(&err_h.join().unwrap_or_default());
let trace: String = {
let chars: Vec<char> = trace.chars().collect();
if chars.len() > 4000 {
chars[chars.len() - 4000..].iter().collect()
} else {
trace
}
};
match status {
Some(st) => (st.success(), trace),
None if timed_out => (false, format!("{trace}\n[verify timed out]")),
None => (false, trace),
}
}
fn strip_code_fences(text: &str) -> String {
let trimmed = text.trim();
if !trimmed.starts_with("```") {
return text.to_string();
}
let mut lines: Vec<&str> = trimmed.lines().collect();
let has_opening = matches!(lines.first(), Some(l) if l.starts_with("```"));
let has_closing = lines.len() > 1 && matches!(lines.last(), Some(l) if l.trim() == "```");
if !(has_opening && has_closing) {
return text.to_string();
}
lines.remove(0); lines.pop(); lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("axiom_solve_{}_{}", std::process::id(), name));
p
}
fn grep_fixed(path: &Path) -> (String, Vec<String>) {
(
posix_shell(),
vec![
"-c".to_string(),
format!("grep -q FIXED '{}'", path.display()),
],
)
}
#[test]
fn verified_patch_is_kept_only_when_it_goes_green() {
let path = tmp("green.txt");
std::fs::write(&path, "BROKEN").unwrap();
let (cmd, args) = grep_fixed(&path);
let kept = apply_verified_patch_iterative(&cmd, &args, None, &path, 1, "", |_f, _o| {
Some("FIXED".into())
});
assert!(kept, "a patch that makes verify pass must be kept");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "FIXED");
let _ = std::fs::remove_file(&path);
}
#[test]
fn rejected_patch_is_rolled_back_byte_for_byte() {
let path = tmp("red.txt");
std::fs::write(&path, "BROKEN").unwrap();
let (cmd, args) = grep_fixed(&path);
let kept = apply_verified_patch_iterative(&cmd, &args, None, &path, 1, "", |_f, _o| {
Some("STILL WRONG".into())
});
assert!(!kept, "a patch that does not pass verify must be rejected");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"BROKEN",
"rejected patch must restore the original byte-for-byte"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn noop_or_empty_candidate_is_not_applied() {
let path = tmp("noop.txt");
std::fs::write(&path, "SAME").unwrap();
let (cmd, args) = grep_fixed(&path);
assert!(!apply_verified_patch_iterative(&cmd, &args, None, &path, 1, "", |_f, o| Some(o.to_string())));
assert!(!apply_verified_patch_iterative(&cmd, &args, None, &path, 1, "", |_f, _o| Some(" ".into())));
assert!(!apply_verified_patch_iterative(&cmd, &args, None, &path, 1, "", |_f, _o| None));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "SAME");
let _ = std::fs::remove_file(&path);
}
#[test]
fn iterative_repair_retries_with_feedback_until_green() {
let path = tmp("iter.txt");
std::fs::write(&path, "BROKEN").unwrap();
let cmd = posix_shell();
let args = vec![
"-c".to_string(),
format!("grep -q FIXED '{}' || {{ echo NEED_FIXED >&2; exit 1; }}", path.display()),
];
let mut calls = 0usize;
let mut saw_feedback = false;
let kept = apply_verified_patch_iterative(&cmd, &args, None, &path, 3, "", |failure, _o| {
calls += 1;
if failure.trim().is_empty() {
Some("STILL WRONG".into()) } else {
saw_feedback = true; Some("FIXED".into())
}
});
assert!(kept, "iterative repair should succeed once it proposes FIXED");
assert!(calls >= 2, "should have retried at least once (calls={calls})");
assert!(saw_feedback, "later attempts must receive the failure trace");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "FIXED");
let _ = std::fs::remove_file(&path);
}
#[test]
fn run_verify_reflects_exit_code() {
assert!(run_verify(&posix_shell(), &["-c".into(), "exit 0".into()], None));
assert!(!run_verify(&posix_shell(), &["-c".into(), "exit 1".into()], None));
}
#[test]
fn run_verify_capture_returns_stdout_stderr_and_status() {
let (ok, trace) = run_verify_capture(
&posix_shell(),
&["-c".into(), "echo out_marker; echo err_marker >&2; exit 3".into()],
None,
);
assert!(!ok, "exit 3 → not success");
assert!(trace.contains("out_marker"), "stdout captured");
assert!(trace.contains("err_marker"), "stderr captured");
}
#[test]
fn strip_code_fences_unwraps_only_paired_fences() {
assert_eq!(strip_code_fences("fn main() {}"), "fn main() {}");
assert_eq!(strip_code_fences("```rust\nfn main() {}\n```"), "fn main() {}");
assert_eq!(strip_code_fences("```\nabc\ndef\n```"), "abc\ndef");
assert_eq!(strip_code_fences("```rust\nfn x() {}"), "```rust\nfn x() {}");
}
}