use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use car_inference::{GenerateRequest, InferenceResult};
use serde_json::{json, Value};
use super::heal_claims::ClaimStore;
use super::heal_gate::GateOutcome;
use super::heal_intake::{Checkout, HealTarget, RawPullRequest};
use super::heal_live::CoderRunner;
use super::heal_runner::{delivery_branch, LiveCoderRunner, Reviewer};
use super::heal_select::{Candidate, CandidateKind};
use super::heal_tick::{
tick, Attempt, DeliverRefusal, Intent, NoClaimSink, RunFailure, TickIo, TickOutcome,
};
use super::merge::{
CiCheck, CiState, CiSummary, GhError, GitHubApi, PrDeliveryOutcome, PrRecord, PrState,
};
use super::native_loop::TurnGenerator;
use super::provenance::{ProvenanceTier, SessionSeed};
use super::router::EngineChoice;
use super::session::CoderState;
use crate::session::ServerState;
struct Script {
turns: Vec<InferenceResult>,
cursor: AtomicUsize,
}
fn turn(text: &str, tool_calls: Value) -> InferenceResult {
turn_as("scripted", text, tool_calls)
}
fn turn_as(model: &str, text: &str, tool_calls: Value) -> InferenceResult {
serde_json::from_value(json!({
"text": text,
"tool_calls": tool_calls,
"trace_id": "heal-e2e",
"model_used": model,
"latency_ms": 0,
}))
.expect("scripted InferenceResult shape")
}
#[async_trait::async_trait]
impl TurnGenerator for Script {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
let i = self.cursor.fetch_add(1, Ordering::SeqCst);
self.turns
.get(i)
.cloned()
.ok_or_else(|| "heal e2e script exhausted".to_string())
}
}
fn script() -> Arc<dyn TurnGenerator> {
Arc::new(Script {
cursor: AtomicUsize::new(0),
turns: vec![
turn(
&json!({
"description": "greeting.txt carries the greeting",
"checks": [{
"name": "content",
"command": "grep -q 'hello from car coder' greeting.txt"
}]
})
.to_string(),
json!([]),
),
turn(
"writing greeting.txt",
json!([{
"id": "w1",
"name": "write_file",
"arguments": {"path": "greeting.txt", "content": "hello from car coder\n"}
}]),
),
turn("done — greeting.txt written", json!([])),
],
})
}
fn vacuous_script() -> Arc<dyn TurnGenerator> {
Arc::new(Script {
cursor: AtomicUsize::new(0),
turns: vec![
turn(
&json!({
"description": "the readme mentions the seed",
"checks": [{
"name": "readme",
"command": "grep -q 'seed' README.md"
}]
})
.to_string(),
json!([]),
),
turn(
"adding a note",
json!([{
"id": "w1",
"name": "write_file",
"arguments": {"path": "NOTES.md", "content": "a change the contract does not check\n"}
}]),
),
turn("done", json!([])),
],
})
}
struct Fixed {
model: &'static str,
answer: &'static str,
expects: &'static str,
}
#[async_trait::async_trait]
impl Reviewer for Fixed {
fn model(&self) -> &str {
self.model
}
async fn review(&self, _criteria: &str, diff: &str) -> Result<String, String> {
assert!(
diff.contains(self.expects),
"the panel was not shown the change it is judging: {diff}"
);
Ok(self.answer.to_string())
}
}
#[derive(Default)]
struct FakeGh {
prs: Mutex<Vec<PrRecord>>,
created: Mutex<Vec<(String, String, String, String)>>,
}
impl GitHubApi for FakeGh {
fn auth_status(&self) -> Result<(), GhError> {
Ok(())
}
fn list_prs_for_head(&self, _dir: &Path, head: &str) -> Result<Vec<PrRecord>, GhError> {
Ok(self
.prs
.lock()
.unwrap()
.iter()
.filter(|_| !head.is_empty())
.cloned()
.collect())
}
fn create_pr(
&self,
dir: &Path,
head: &str,
base: &str,
title: &str,
body: &str,
_draft: bool,
) -> Result<PrRecord, GhError> {
self.created.lock().unwrap().push((
dir.display().to_string(),
head.to_string(),
base.to_string(),
format!("{title}\n{body}"),
));
let record = PrRecord {
number: 1,
state: PrState::Open,
url: "https://example.invalid/pull/1".into(),
is_draft: false,
base: base.to_string(),
};
self.prs.lock().unwrap().push(record.clone());
Ok(record)
}
fn set_pr_body(&self, _dir: &Path, _number: u64, _body: &str) -> Result<(), GhError> {
Ok(())
}
fn ci_for_sha(&self, _dir: &Path, _number: u64, head_sha: &str) -> Result<CiSummary, GhError> {
Ok(CiSummary {
observation_error: None,
head_sha: head_sha.to_string(),
state: CiState::Green,
checks: vec![CiCheck {
name: "e2e".into(),
state: CiState::Green,
}],
})
}
}
fn git(dir: &Path, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn provision_repo() -> (tempfile::TempDir, tempfile::TempDir) {
let origin = tempfile::tempdir().unwrap();
let dir = tempfile::tempdir().unwrap();
git(origin.path(), &["init", "-q", "--bare", "-b", "main"]);
git(dir.path(), &["init", "-q", "-b", "main"]);
git(dir.path(), &["config", "user.name", "heal"]);
git(dir.path(), &["config", "user.email", "heal@car"]);
std::fs::write(dir.path().join("README.md"), "seed\n").unwrap();
git(dir.path(), &["add", "-A"]);
git(dir.path(), &["commit", "-qm", "seed"]);
git(
dir.path(),
&["remote", "add", "origin", origin.path().to_str().unwrap()],
);
git(dir.path(), &["push", "-q", "origin", "main"]);
(dir, origin)
}
fn origin_head(origin: &Path, branch: &str) -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(origin)
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
.output()
.expect("git runs");
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
}
struct RealCoderIo {
runner: LiveCoderRunner,
item: Candidate,
comments: Mutex<Vec<String>>,
}
#[async_trait::async_trait]
impl TickIo for RealCoderIo {
async fn candidates(&self, _t: &HealTarget) -> Result<Vec<Candidate>, String> {
Ok(vec![self.item.clone()])
}
async fn open_prs(&self, _t: &HealTarget) -> Result<Vec<RawPullRequest>, String> {
Ok(Vec::new())
}
async fn intent_for(&self, _i: &Candidate) -> Result<Intent, String> {
Ok(Intent::Seed(SessionSeed::from_trusted(
"create greeting.txt containing the text: hello from car coder",
)))
}
fn redact(&self, text: &str) -> String {
text.to_string()
}
async fn run_coder(
&self,
target: &HealTarget,
item: &Candidate,
seed: &SessionSeed,
) -> Result<Attempt, RunFailure> {
self.runner.run(target, item, seed).await
}
async fn deliver(
&self,
target: &HealTarget,
item: &Candidate,
session_id: &str,
gate: &GateOutcome,
) -> Result<PrDeliveryOutcome, DeliverRefusal> {
assert!(!gate.summary().is_empty());
let reference = if target.is_cross_repo() {
format!("{}#{}", item.repo, item.number)
} else {
format!("#{}", item.number)
};
let body = format!("self-heal for {reference}. Gate: {}", gate.summary());
self.runner.deliver(target, item, session_id, &body).await
}
async fn abandon(&self, session_id: &str) {
self.runner.abandon(session_id).await;
}
async fn comment(&self, _i: &Candidate, text: &str) -> Result<(), String> {
self.comments.lock().unwrap().push(text.to_string());
Ok(())
}
fn now_ms(&self) -> u64 {
10_000_000
}
}
struct Harness {
io: Arc<RealCoderIo>,
gh: Arc<FakeGh>,
state: Arc<ServerState>,
target: HealTarget,
repo: tempfile::TempDir,
origin: tempfile::TempDir,
_state_dir: tempfile::TempDir,
_journal: tempfile::TempDir,
}
fn harness(panel: Vec<Arc<dyn Reviewer>>) -> Harness {
harness_with(panel, script())
}
fn harness_with(panel: Vec<Arc<dyn Reviewer>>, generator: Arc<dyn TurnGenerator>) -> Harness {
harness_canonical(panel, generator, Arc::new(|m: &str| m.to_string()))
}
fn harness_auto(panel: Vec<Arc<dyn Reviewer>>, generator: Arc<dyn TurnGenerator>) -> Harness {
harness_inner(
panel,
generator,
Arc::new(|m: &str| m.to_string()),
EngineChoice::Auto,
)
}
fn harness_canonical(
panel: Vec<Arc<dyn Reviewer>>,
generator: Arc<dyn TurnGenerator>,
canonical_model: Arc<dyn Fn(&str) -> String + Send + Sync>,
) -> Harness {
harness_inner(panel, generator, canonical_model, EngineChoice::Native)
}
fn harness_inner(
panel: Vec<Arc<dyn Reviewer>>,
generator: Arc<dyn TurnGenerator>,
canonical_model: Arc<dyn Fn(&str) -> String + Send + Sync>,
engine: EngineChoice,
) -> Harness {
let (repo, origin) = provision_repo();
let state_dir = tempfile::tempdir().unwrap();
let journal = tempfile::tempdir().unwrap();
let state = Arc::new(ServerState::standalone(journal.path().to_path_buf()));
let gh = Arc::new(FakeGh::default());
let runner = LiveCoderRunner {
state: state.clone(),
generator,
state_dir: state_dir.path().to_path_buf(),
routing_exclusions: panel
.iter()
.map(|reviewer| reviewer.model().to_string())
.collect(),
reviewers: panel,
max_wall_secs: 300,
max_iterations: Some(6),
engine,
model: None,
canonical_model,
github: gh.clone(),
};
let target = HealTarget {
repo: "acme/widgets".into(),
fix_repo: None,
checkout: Some(Checkout::Local(repo.path().to_path_buf())),
label: "self-heal".into(),
base: "main".into(),
};
let io = Arc::new(RealCoderIo {
runner,
item: Candidate {
repo: "acme/widgets".into(),
number: 7,
tier: Some(ProvenanceTier::Maintainer),
labelled: true,
created_ms: 1,
kind: CandidateKind::Issue,
},
comments: Mutex::new(Vec::new()),
});
Harness {
io,
gh,
state,
target,
repo,
origin,
_state_dir: state_dir,
_journal: journal,
}
}
async fn only_session(
state: &Arc<ServerState>,
) -> (String, CoderState, Option<std::path::PathBuf>) {
let sessions = state.coder_sessions.lock().await;
assert_eq!(sessions.len(), 1, "expected exactly one coder session");
let (id, entry) = sessions.iter().next().unwrap();
let s = entry.session.lock().await;
(id.clone(), s.state, s.workspace_path.clone())
}
#[tokio::test(flavor = "multi_thread")]
async fn a_tick_takes_an_issue_through_a_real_session_to_a_pull_request() {
let h = harness(vec![
Arc::new(Fixed {
model: "reviewer-a",
answer: "PASS — matches the stated intent",
expects: "greeting.txt",
}),
Arc::new(Fixed {
model: "reviewer-b",
answer: "PASS — correctly scoped",
expects: "greeting.txt",
}),
Arc::new(Fixed {
model: "reviewer-c",
answer: "FAIL — would prefer a test",
expects: "greeting.txt",
}),
]);
let io: Arc<dyn TickIo> = h.io.clone();
let mut claims = ClaimStore::new();
let outcome = tick(&io, &h.target, &mut claims, "run-e2e", &NoClaimSink).await;
let gate_summary = match &outcome {
TickOutcome::Opened {
repo,
number,
pr_url,
gate,
ci,
delivery,
} => {
assert_eq!(repo, "acme/widgets");
assert_eq!(*number, 7);
assert!(pr_url.contains("pull/1"));
assert_eq!(ci.state, CiState::Green);
assert_eq!(
ci.head_sha,
origin_head(h.origin.path(), &delivery_branch(&h.io.item)).unwrap()
);
assert_eq!(
delivery,
&format!(
"delivered with green checks and ready for review at {}",
ci.head_sha
)
);
gate.clone()
}
other => panic!("expected a pull request, got {other:?}"),
};
assert!(gate_summary.contains("2/3"), "{gate_summary}");
let branch = delivery_branch(&h.io.item);
let on_origin = origin_head(h.origin.path(), &branch)
.unwrap_or_else(|| panic!("{branch} never reached the remote"));
let content = git(
h.repo.path(),
&["show", &format!("{on_origin}:greeting.txt")],
);
assert!(content.contains("hello from car coder"), "{content}");
for (where_, files) in [
(
"local",
git(h.repo.path(), &["ls-tree", "--name-only", "main"]),
),
(
"origin",
git(h.origin.path(), &["ls-tree", "--name-only", "main"]),
),
] {
assert!(
!files.contains("greeting.txt"),
"the loop wrote to {where_} main: {files}"
);
}
let created = h.gh.created.lock().unwrap().clone();
assert_eq!(created.len(), 1);
let (dir, head, base, text) = &created[0];
assert_eq!(head, &branch);
assert_eq!(base, "main");
assert_eq!(dir, &h.repo.path().display().to_string());
assert!(text.contains("#7"), "{text}");
assert!(text.contains("2/3"), "{text}");
let (_id, state, worktree) = only_session(&h.state).await;
assert_eq!(state, CoderState::Merged);
if let Some(path) = worktree {
assert!(
!path.exists(),
"the worktree outlived the session: {path:?}"
);
}
assert_eq!(claims.held_by("acme/widgets", 7), Some("run-e2e"));
assert!(claims.attempts().is_empty());
assert!(h.io.comments.lock().unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn a_contract_that_is_green_before_any_edit_never_reaches_the_panel() {
let h = harness_with(
vec![
Arc::new(Fixed {
model: "reviewer-a",
answer: "PASS — looks right",
expects: "NOTES.md",
}),
Arc::new(Fixed {
model: "reviewer-b",
answer: "PASS — agreed",
expects: "NOTES.md",
}),
],
vacuous_script(),
);
let io: Arc<dyn TickIo> = h.io.clone();
let mut claims = ClaimStore::new();
let outcome = tick(&io, &h.target, &mut claims, "run-vacuous", &NoClaimSink).await;
match &outcome {
TickOutcome::Failed { detail } => {
assert!(detail.contains("already passes on"), "{detail}");
assert!(detail.contains("acme/widgets#7"), "{detail}");
assert!(detail.contains("premise"), "{detail}");
}
other => panic!("expected a refusal, got {other:?}"),
}
assert!(
origin_head(h.origin.path(), &delivery_branch(&h.io.item)).is_none(),
"a contract that gates nothing must not deliver"
);
assert!(h.gh.created.lock().unwrap().is_empty(), "no pull request");
let comments = h.io.comments.lock().unwrap();
assert!(
comments.iter().any(|c| c.contains("already passes on")),
"{comments:?}"
);
}
fn script_as(model: &'static str) -> Arc<dyn TurnGenerator> {
Arc::new(Script {
cursor: AtomicUsize::new(0),
turns: vec![
turn_as(
model,
&json!({
"description": "greeting.txt carries the greeting",
"checks": [{
"name": "content",
"command": "grep -q 'hello from car coder' greeting.txt"
}]
})
.to_string(),
json!([]),
),
turn_as(
model,
"writing greeting.txt",
json!([{
"id": "w1",
"name": "write_file",
"arguments": {"path": "greeting.txt", "content": "hello from car coder\n"}
}]),
),
turn_as(model, "done — greeting.txt written", json!([])),
],
})
}
#[tokio::test]
async fn a_model_that_wrote_the_change_may_not_review_it() {
let h = harness(vec![
Arc::new(Fixed {
model: "scripted",
answer: "PASS — looks right to me",
expects: "greeting.txt",
}),
Arc::new(Fixed {
model: "reviewer-b",
answer: "PASS — agreed",
expects: "greeting.txt",
}),
]);
let io: Arc<dyn TickIo> = h.io.clone();
let mut claims = ClaimStore::new();
let outcome = tick(&io, &h.target, &mut claims, "run-self-review", &NoClaimSink).await;
match &outcome {
TickOutcome::Failed { detail } => {
assert!(detail.contains("review its own output"), "{detail}");
assert!(detail.contains("scripted"), "{detail}");
}
other => panic!("a self-reviewing panel must be refused, got {other:?}"),
}
assert!(
origin_head(h.origin.path(), &delivery_branch(&h.io.item)).is_none(),
"a self-reviewed change must not be delivered"
);
assert!(h.gh.created.lock().unwrap().is_empty(), "no pull request");
}
#[tokio::test]
async fn a_seat_spelled_by_id_still_catches_an_author_reported_by_name() {
let h = harness_canonical(
vec![
Arc::new(Fixed {
model: "openrouter/acme/model-x",
answer: "PASS — looks right to me",
expects: "greeting.txt",
}),
Arc::new(Fixed {
model: "reviewer-b",
answer: "PASS — agreed",
expects: "greeting.txt",
}),
],
script_as("Model X"),
Arc::new(|m: &str| match m {
"Model X" | "openrouter/acme/model-x" => "openrouter/acme/model-x".to_string(),
other => other.to_string(),
}),
);
let io: Arc<dyn TickIo> = h.io.clone();
let mut claims = ClaimStore::new();
let outcome = tick(&io, &h.target, &mut claims, "run-canonical", &NoClaimSink).await;
match &outcome {
TickOutcome::Failed { detail } => {
assert!(detail.contains("review its own output"), "{detail}");
assert!(detail.contains("Model X"), "{detail}");
assert!(detail.contains("openrouter/acme/model-x"), "{detail}");
}
other => panic!("one model under two spellings must be refused, got {other:?}"),
}
assert!(h.gh.created.lock().unwrap().is_empty(), "no pull request");
}
#[tokio::test]
async fn an_auto_session_that_resolved_native_is_refused_the_same_way() {
let h = harness_auto(
vec![
Arc::new(Fixed {
model: "reviewer-a",
answer: "PASS — looks right to me",
expects: "greeting.txt",
}),
Arc::new(Fixed {
model: "reviewer-b",
answer: "PASS — agreed",
expects: "greeting.txt",
}),
],
script_as(""),
);
let io: Arc<dyn TickIo> = h.io.clone();
let mut claims = ClaimStore::new();
let outcome = tick(&io, &h.target, &mut claims, "run-auto-native", &NoClaimSink).await;
match &outcome {
TickOutcome::Failed { detail } => {
assert!(detail.contains("no model recorded against it"), "{detail}");
}
other => panic!("`auto` that resolved native must be refused too, got {other:?}"),
}
assert!(h.gh.created.lock().unwrap().is_empty(), "no pull request");
}
#[tokio::test]
async fn a_native_session_with_no_recorded_author_is_refused() {
let h = harness_with(
vec![
Arc::new(Fixed {
model: "reviewer-a",
answer: "PASS — looks right to me",
expects: "greeting.txt",
}),
Arc::new(Fixed {
model: "reviewer-b",
answer: "PASS — agreed",
expects: "greeting.txt",
}),
],
script_as(""),
);
let io: Arc<dyn TickIo> = h.io.clone();
let mut claims = ClaimStore::new();
let outcome = tick(
&io,
&h.target,
&mut claims,
"run-unattributed",
&NoClaimSink,
)
.await;
match &outcome {
TickOutcome::Failed { detail } => {
assert!(detail.contains("no model recorded against it"), "{detail}");
}
other => panic!("an unattributed native change must be refused, got {other:?}"),
}
assert!(h.gh.created.lock().unwrap().is_empty(), "no pull request");
}
#[tokio::test]
async fn a_panel_that_refuses_publishes_nothing_at_all() {
let h = harness(vec![
Arc::new(Fixed {
model: "reviewer-a",
answer: "FAIL — wrong approach",
expects: "greeting.txt",
}),
Arc::new(Fixed {
model: "reviewer-b",
answer: "FAIL — out of scope",
expects: "greeting.txt",
}),
Arc::new(Fixed {
model: "reviewer-c",
answer: "PASS — fine by me",
expects: "greeting.txt",
}),
]);
let io: Arc<dyn TickIo> = h.io.clone();
let mut claims = ClaimStore::new();
let outcome = tick(&io, &h.target, &mut claims, "run-reject", &NoClaimSink).await;
match &outcome {
TickOutcome::Rejected { number, gate, .. } => {
assert_eq!(*number, 7);
assert!(gate.starts_with("1/2 required approvals"), "{gate}");
assert!(gate.contains("reviewer-a: FAIL"), "{gate}");
assert!(gate.contains("reviewer-b: FAIL"), "{gate}");
}
other => panic!("expected a rejection, got {other:?}"),
}
let local = git(h.repo.path(), &["branch", "--list", "car/*"]);
assert!(
local.trim().is_empty(),
"a refused change published: {local}"
);
assert_eq!(
origin_head(h.origin.path(), &delivery_branch(&h.io.item)),
None
);
assert!(
h.gh.created.lock().unwrap().is_empty(),
"a refused change reached a pull request"
);
let (_id, state, worktree) = only_session(&h.state).await;
assert_eq!(state, CoderState::Abandoned);
if let Some(path) = worktree {
assert!(
!path.exists(),
"the worktree outlived the session: {path:?}"
);
}
assert_eq!(claims.held_by("acme/widgets", 7), None);
assert!(
!claims.attempts().is_empty(),
"the failure was not recorded"
);
let comments = h.io.comments.lock().unwrap().clone();
assert_eq!(comments.len(), 1);
assert!(comments[0].contains("reviewer-a"), "{}", comments[0]);
}