use std::path::Path;
use std::sync::Arc;
use super::heal_gate::{GateOutcome, Unreachable, Verdict};
use super::heal_intake::{HealTarget, PullRequestApi, RawPullRequest};
use super::heal_select::{Candidate, CandidateKind};
use super::heal_tick::{Attempt, DeliverRefusal, Intent, RunFailure, TickIo};
use super::merge::PrDeliveryOutcome;
use super::provenance::{
resolve_tier, LocalSignatures, PermissionOracle, ProvenanceRefusal, SessionSeed,
};
#[async_trait::async_trait]
pub trait CoderRunner: Send + Sync {
async fn run(
&self,
target: &HealTarget,
item: &Candidate,
seed: &SessionSeed,
) -> Result<Attempt, RunFailure>;
async fn deliver(
&self,
target: &HealTarget,
item: &Candidate,
session_id: &str,
body: &str,
) -> Result<PrDeliveryOutcome, DeliverRefusal>;
async fn abandon(&self, session_id: &str);
}
pub struct LiveTickIo {
pub issues: Arc<dyn super::fix_issues::IssueApi>,
pub prs: Arc<dyn PullRequestApi>,
pub oracle: Arc<dyn PermissionOracle>,
pub coder: Arc<dyn CoderRunner>,
pub local_signatures: LocalSignatures,
pub redactor: car_selfheal::redact::Redactor,
pub panel: Vec<super::heal_review::PanelSeat>,
}
fn pr_body(reference: &str, gate_summary: &str, panel: &[super::heal_review::PanelSeat]) -> String {
let seats = if panel.is_empty() {
String::new()
} else {
let rendered: Vec<String> = panel
.iter()
.map(|s| match &s.vendor {
Some(v) => format!("{} ({v})", s.model),
None => format!("{} (vendor unknown)", s.model),
})
.collect();
format!("\n\nPanel: {}", rendered.join(", "))
};
let caveat = match super::heal_review::correlation_warning(panel) {
Some(w) => format!("\n\n> **This panel is not demonstrably independent.** {w}"),
None => String::new(),
};
format!(
"Opened by CAR's self-healing loop for {reference}.\n\n\
Gate: {gate_summary}{seats}{caveat}\n\n\
This was written by an automated run. It is a pull request, not a merge: \
a human decides."
)
}
fn untiered_candidates(
target: &HealTarget,
issues: &[super::provenance::RawIssue],
prs: &[RawPullRequest],
) -> Vec<Candidate> {
let out: Vec<Candidate> = issues
.iter()
.map(|i| Candidate {
repo: i.repo().to_string(),
number: i.number(),
tier: None,
labelled: i.has_label(&target.label),
created_ms: i.created_ms(),
kind: CandidateKind::Issue,
})
.collect();
let _ = prs;
out
}
#[async_trait::async_trait]
impl TickIo for LiveTickIo {
async fn candidates(&self, target: &HealTarget) -> Result<Vec<Candidate>, String> {
let issues = self
.issues
.list_open_issues(&target.repo)
.map_err(|e| format!("list issues on {}: {}", target.repo, e.message))?;
let prs = self
.prs
.list_open_prs(target.coverage_repo())
.map_err(|e| {
format!(
"list pull requests on {}: {}",
target.coverage_repo(),
e.message
)
})?;
Ok(untiered_candidates(target, &issues, &prs))
}
async fn open_prs(&self, target: &HealTarget) -> Result<Vec<RawPullRequest>, String> {
self.prs.list_open_prs(target.coverage_repo()).map_err(|e| {
format!(
"list pull requests on {}: {}",
target.coverage_repo(),
e.message
)
})
}
async fn intent_for(&self, item: &Candidate) -> Result<Intent, String> {
let open = self
.issues
.list_open_issues(&item.repo)
.map_err(|e| format!("re-read {}: {}", item.repo, e.message))?;
let Some(raw) = open.into_iter().find(|i| i.number() == item.number) else {
return Ok(Intent::Gone);
};
let tiered = resolve_tier(
raw,
self.oracle.as_ref(),
&self.local_signatures,
std::time::SystemTime::now(),
);
let tier = tiered.tier();
match tiered.seed_session(std::time::SystemTime::now()) {
Ok(seed) => Ok(Intent::Seed(seed)),
Err(refusal) => {
tracing::info!(
repo = %item.repo,
number = item.number,
refusal = ?refusal,
"self-heal: item not cleared to seed a session"
);
Ok(Intent::Refused {
tier,
stale: matches!(refusal, ProvenanceRefusal::StaleTier { .. }),
})
}
}
}
fn redact(&self, text: &str) -> String {
self.redactor.redact(text)
}
async fn run_coder(
&self,
target: &HealTarget,
item: &Candidate,
seed: &SessionSeed,
) -> Result<Attempt, RunFailure> {
self.coder.run(target, item, seed).await
}
async fn deliver(
&self,
target: &HealTarget,
item: &Candidate,
session_id: &str,
gate: &GateOutcome,
) -> Result<PrDeliveryOutcome, DeliverRefusal> {
let reference = if target.is_cross_repo() {
format!("{}#{}", item.repo, item.number)
} else {
format!("#{}", item.number)
};
let body = pr_body(
&reference,
&self.redactor.redact(&gate.summary()),
&self.panel,
);
self.coder.deliver(target, item, session_id, &body).await
}
async fn abandon(&self, session_id: &str) {
self.coder.abandon(session_id).await;
}
async fn comment(&self, item: &Candidate, text: &str) -> Result<(), String> {
let args: Vec<String> = vec![
"issue".into(),
"comment".into(),
item.number.to_string(),
"--repo".into(),
item.repo.clone(),
"--body".into(),
text.to_string(),
];
super::merge::gh(Path::new("."), &args)
.map(|_| ())
.map_err(|e| format!("comment on {}#{}: {}", item.repo, item.number, e.message))
}
fn now_ms(&self) -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
}
pub fn parse_verdict(model: &str, answer: &str) -> Result<Verdict, Unreachable> {
let verdict = answer
.split(|c: char| c.is_whitespace())
.map(|t| t.trim_matches(|c: char| !c.is_ascii_alphabetic()))
.find_map(|t| match t.to_ascii_uppercase().as_str() {
"PASS" => Some(true),
"FAIL" => Some(false),
_ => None,
});
let Some(pass) = verdict else {
return Err(Unreachable {
model: model.to_string(),
error: "reviewer answer contained neither a PASS nor a FAIL verdict".into(),
});
};
Ok(Verdict {
model: model.to_string(),
pass,
reason: answer.trim().chars().take(280).collect(),
})
}
#[cfg(test)]
mod tests {
use super::super::heal_review::PanelSeat;
fn seat(model: &str, vendor: Option<&str>) -> PanelSeat {
PanelSeat {
model: model.to_string(),
vendor: vendor.map(str::to_string),
}
}
#[test]
fn the_pull_request_body_carries_the_panel_and_its_caveat() {
let body = pr_body(
"#42",
"approved by 3/3 reviewers, checks green",
&[
seat("gpt-5.5", Some("openai")),
seat("gpt-5.4", Some("openai")),
seat("gpt-5.6-sol", Some("openai")),
],
);
assert!(body.contains("#42"), "{body}");
assert!(body.contains("approved by 3/3 reviewers"), "{body}");
assert!(body.contains("gpt-5.5 (openai)"), "{body}");
assert!(body.contains("not demonstrably independent"), "{body}");
assert!(body.contains("openai serves 3 of the 3 seats"), "{body}");
}
#[test]
fn an_independent_panel_gets_seats_but_no_caveat() {
let body = pr_body(
"acme/repo#7",
"approved by 2/3 reviewers, checks green",
&[
seat("gpt-5.5", Some("openai")),
seat("claude-opus-5", Some("anthropic")),
seat("gemini-3.1", Some("google")),
],
);
assert!(body.contains("acme/repo#7"), "{body}");
assert!(body.contains("claude-opus-5 (anthropic)"), "{body}");
assert!(!body.contains("not demonstrably independent"), "{body}");
}
#[test]
fn no_panel_adds_no_panel_section() {
let body = pr_body("#1", "approved by 1/1 reviewers, checks green", &[]);
assert!(!body.contains("Panel:"), "{body}");
assert!(!body.contains("not demonstrably independent"), "{body}");
}
use super::*;
#[test]
fn a_clear_pass_and_a_clear_fail_are_read() {
assert!(parse_verdict("a", "PASS — scoped correctly").unwrap().pass);
assert!(
!parse_verdict("a", "FAIL: changes unrelated code")
.unwrap()
.pass
);
}
#[test]
fn an_unreadable_answer_is_unreachable_never_a_pass() {
assert!(parse_verdict("a", "I think it's probably fine?").is_err());
assert!(parse_verdict("a", "").is_err());
}
#[test]
fn a_reason_mentioning_the_other_word_does_not_destroy_the_verdict() {
let v = parse_verdict("a", "PASS — nothing here would fail the existing checks").unwrap();
assert!(v.pass);
let v = parse_verdict("a", "FAIL — this would not pass review by a human").unwrap();
assert!(!v.pass);
}
#[test]
fn the_first_verdict_token_wins() {
let v = parse_verdict("a", "FAIL. It might pass once the test is added.").unwrap();
assert!(!v.pass);
}
#[test]
fn a_verdict_inside_prose_is_still_read() {
assert!(
parse_verdict("a", "My verdict: PASS, it matches the intent")
.unwrap()
.pass
);
}
#[test]
fn a_word_containing_pass_is_not_a_verdict() {
assert!(parse_verdict("a", "the tests bypassed the new branch").is_err());
assert!(parse_verdict("a", "this passes muster").is_err());
}
#[test]
fn a_reason_is_bounded() {
let long = "PASS ".to_string() + &"x".repeat(1000);
let v = parse_verdict("a", &long).unwrap();
assert!(
v.reason.len() <= 280,
"an unbounded reason reaches a tracker"
);
}
#[test]
fn scanned_candidates_are_untiered_not_public() {
let issues = vec![crate::coder::provenance::RawIssue::new(
"acme/widgets",
7,
"maintainer",
"a title",
"a body",
vec!["self-heal".to_string()],
1,
)];
let target = HealTarget {
repo: "acme/widgets".into(),
fix_repo: None,
checkout: Some(crate::coder::heal_intake::Checkout::Local("/tmp/x".into())),
label: "self-heal".into(),
base: "main".into(),
};
let out = untiered_candidates(&target, &issues, &[]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].tier, None, "a scanned candidate is untiered");
assert!(out[0].labelled, "the cheap checks still ran");
}
#[test]
fn public_still_authorises_nothing_where_it_is_resolved() {
assert!(!crate::coder::provenance::ProvenanceTier::Public.may_seed_session());
assert!(!crate::coder::provenance::ProvenanceTier::Public.may_source_contract());
}
}