car_server_core/coder/heal_live.rs
1//! The real [`TickIo`]: live GitHub, live provenance, live coder session.
2//!
3//! Everything above this file was tested against a fake. This is the half that
4//! actually touches the world, and it is deliberately thin — it fetches facts
5//! and performs effects, and decides nothing. Every judgement the loop makes
6//! lives in the pure cores (`heal_select`, `heal_gate`) so it can be reasoned
7//! about without a network.
8//!
9//! ## Tiering is lazy, and that is a correctness property
10//!
11//! Resolving an author's permission is one `gh api` call, and
12//! [`super::provenance::resolve_tier`] deliberately memoizes nothing —
13//! "Nothing in this function memoizes, and nothing should be added that does."
14//! Tiering all 200 scanned issues up front would be 200 calls per tick per
15//! target, and worse, the chosen item's tier would be minutes stale by the time
16//! it is used, past the freshness window the gate enforces.
17//!
18//! So candidates are built cheaply first — number, label, creation time — and
19//! tiered only when one is actually reached, in [`TickIo::intent_for`]. That is
20//! why [`Candidate::tier`] is `None` here: nobody has asked yet.
21//!
22//! It used to be `ProvenanceTier::Public`, on the reasoning that the absence of
23//! a claim about the author should authorise nothing. The reasoning was right
24//! and the encoding was wrong. `Public` is a *resolved* tier meaning "this
25//! author is untrusted", and `heal_select::ineligible` checks authorisation
26//! before anything else — correctly — so every candidate the live path produced
27//! was rejected as `UntrustedAuthor` before selection ever looked at a label.
28//! The loop could not select an item at all, and reported a reason that was not
29//! true of any of those authors. Every unit test set a tier by hand, so only a
30//! live run against a real tracker showed it.
31//!
32//! `None` says the true thing, and it does not widen what may run: nothing
33//! starts a session on an unresolved tier, because `intent_for` below resolves
34//! it for real and yields no seed when it refuses.
35//!
36//! ## What this file may not do
37//!
38//! It may not construct a [`GateOutcome`] — only `heal_gate::decide` can — and
39//! it may not turn an issue body into anything a coder session or a reviewer
40//! sees except through [`SessionSeed`]. Both are enforced by types rather than
41//! by this comment.
42
43use std::path::Path;
44use std::sync::Arc;
45
46use super::heal_gate::{GateOutcome, Unreachable, Verdict};
47use super::heal_intake::{HealTarget, PullRequestApi, RawPullRequest};
48use super::heal_select::{Candidate, CandidateKind};
49use super::heal_tick::{Attempt, DeliverRefusal, Intent, RunFailure, TickIo};
50use super::merge::PrDeliveryOutcome;
51use super::provenance::{
52 resolve_tier, LocalSignatures, PermissionOracle, ProvenanceRefusal, SessionSeed,
53};
54
55/// Runs a coder session and a review panel for one item.
56///
57/// Separate from [`LiveTickIo`] because it is the piece that needs the daemon's
58/// runtime, its inference engine and a worktree, while everything else here
59/// needs only `gh`. Keeping the seam means the GitHub half is testable — and
60/// reviewable — without standing up an engine.
61#[async_trait::async_trait]
62pub trait CoderRunner: Send + Sync {
63 /// Run the session, evaluate the outcome contract, and poll the review
64 /// panel. Returns the evidence; the verdict is computed by the caller.
65 async fn run(
66 &self,
67 target: &HealTarget,
68 item: &Candidate,
69 seed: &SessionSeed,
70 ) -> Result<Attempt, RunFailure>;
71
72 /// Commit, push, and reconcile exactly one pull request for the item.
73 ///
74 /// `body` is supplied by the caller rather than built here because it must
75 /// be redacted, and the redactor lives with the GitHub half.
76 async fn deliver(
77 &self,
78 target: &HealTarget,
79 item: &Candidate,
80 session_id: &str,
81 body: &str,
82 ) -> Result<PrDeliveryOutcome, DeliverRefusal>;
83
84 /// Close a session out without delivering, releasing its worktree.
85 async fn abandon(&self, session_id: &str);
86}
87
88/// The live implementation.
89pub struct LiveTickIo {
90 pub issues: Arc<dyn super::fix_issues::IssueApi>,
91 pub prs: Arc<dyn PullRequestApi>,
92 pub oracle: Arc<dyn PermissionOracle>,
93 pub coder: Arc<dyn CoderRunner>,
94 /// Signatures this runtime authored, for the `Runtime` tier.
95 pub local_signatures: LocalSignatures,
96 pub redactor: car_selfheal::redact::Redactor,
97 /// The review panel with each seat resolved to the vendor serving it.
98 ///
99 /// Carried down to DELIVERY, not left in `heal.status`, because the pull
100 /// request body is where the gate's verdict is actually read. "approved by
101 /// 3/3 reviewers" is the number a human uses to decide how closely to read
102 /// the change, and three seats served by one vendor approve at close to a
103 /// single model's false-approval rate. Reporting that only to an operator
104 /// who runs `car heal status` corrects the claim everywhere except where it
105 /// is made.
106 pub panel: Vec<super::heal_review::PanelSeat>,
107}
108
109/// The pull-request body: what the gate decided, and what decided it.
110///
111/// Pure so the thing a human actually reads can be asserted directly. The gate
112/// summary alone says "approved by 3/3 reviewers", and a reader given only that
113/// count cannot tell three reviewers from one vendor asked three times — so the
114/// seats and, when it applies, the caveat travel WITH the verdict. A correction
115/// that lives only in `heal.status` or a boot-time log corrects the claim
116/// everywhere except where the claim is made.
117fn pr_body(reference: &str, gate_summary: &str, panel: &[super::heal_review::PanelSeat]) -> String {
118 let seats = if panel.is_empty() {
119 String::new()
120 } else {
121 let rendered: Vec<String> = panel
122 .iter()
123 .map(|s| match &s.vendor {
124 Some(v) => format!("{} ({v})", s.model),
125 None => format!("{} (vendor unknown)", s.model),
126 })
127 .collect();
128 format!("\n\nPanel: {}", rendered.join(", "))
129 };
130 let caveat = match super::heal_review::correlation_warning(panel) {
131 Some(w) => format!("\n\n> **This panel is not demonstrably independent.** {w}"),
132 None => String::new(),
133 };
134 format!(
135 "Opened by CAR's self-healing loop for {reference}.\n\n\
136 Gate: {gate_summary}{seats}{caveat}\n\n\
137 This was written by an automated run. It is a pull request, not a merge: \
138 a human decides."
139 )
140}
141
142/// Build the candidate list without spending a permission call on any of them.
143/// See the module docs on lazy tiering.
144///
145/// Free rather than a method because it is pure — the whole point of the split
146/// is that what the loop *decides* can be tested without a network.
147fn untiered_candidates(
148 target: &HealTarget,
149 issues: &[super::provenance::RawIssue],
150 prs: &[RawPullRequest],
151) -> Vec<Candidate> {
152 let out: Vec<Candidate> = issues
153 .iter()
154 .map(|i| Candidate {
155 repo: i.repo().to_string(),
156 number: i.number(),
157 // Nobody has asked yet. NOT `Public`, which is a resolved
158 // "this author is untrusted" and made selection reject every
159 // candidate before it ever looked at the label. `intent_for`
160 // resolves this for the one item that is actually reached.
161 tier: None,
162 labelled: i.has_label(&target.label),
163 created_ms: i.created_ms(),
164 kind: CandidateKind::Issue,
165 })
166 .collect();
167
168 // Failing pull requests are NOT candidates, and adding them back needs
169 // more than deleting this comment.
170 //
171 // They were, and the path was dead in a way that starved the whole target.
172 // `intent_for` resolves an item by number against `gh issue list`, which
173 // does not return pull requests, so every such candidate resolved to
174 // `Intent::Gone` — and `Gone`, correctly for the case it was written for,
175 // records no failure. Combined with `created_ms: 0`, which sorts a pull
176 // request ahead of every real issue, ONE labelled red pull request pinned
177 // the target permanently: chosen first on every tick, reported `Gone`, with
178 // no issue behind it ever reached.
179 //
180 // Two things must be settled before this returns. (1) Tiering is
181 // issue-shaped — the only `resolve_tier` call site reads the issue list —
182 // so a pull-request candidate has no route to a provenance tier at all.
183 // (2) The proposal's open question 3 has not been answered: pushing to
184 // someone else's pull-request branch is a different act from opening one,
185 // and `delivery_branch` would mint `car/heal/<repo>-<pr>`, a fresh branch
186 // unrelated to that pull request's head — so a "fix" would open a SECOND
187 // pull request for the same work.
188 let _ = prs;
189 out
190}
191
192#[async_trait::async_trait]
193impl TickIo for LiveTickIo {
194 async fn candidates(&self, target: &HealTarget) -> Result<Vec<Candidate>, String> {
195 let issues = self
196 .issues
197 .list_open_issues(&target.repo)
198 .map_err(|e| format!("list issues on {}: {}", target.repo, e.message))?;
199 let prs = self
200 .prs
201 .list_open_prs(target.coverage_repo())
202 .map_err(|e| {
203 format!(
204 "list pull requests on {}: {}",
205 target.coverage_repo(),
206 e.message
207 )
208 })?;
209 Ok(untiered_candidates(target, &issues, &prs))
210 }
211
212 async fn open_prs(&self, target: &HealTarget) -> Result<Vec<RawPullRequest>, String> {
213 // The COVERAGE repo, not the queue repo. A pull request lands where its
214 // branch was pushed, so asking the tracker whether an issue is covered
215 // queries a repository that can never hold the answer.
216 self.prs.list_open_prs(target.coverage_repo()).map_err(|e| {
217 format!(
218 "list pull requests on {}: {}",
219 target.coverage_repo(),
220 e.message
221 )
222 })
223 }
224
225 async fn intent_for(&self, item: &Candidate) -> Result<Intent, String> {
226 // Re-read the item and tier it NOW. The candidate's tier is a
227 // placeholder; this is where the authorisation actually happens, on
228 // fresh permission data, for the one item that was selected.
229 let open = self
230 .issues
231 .list_open_issues(&item.repo)
232 .map_err(|e| format!("re-read {}: {}", item.repo, e.message))?;
233 let Some(raw) = open.into_iter().find(|i| i.number() == item.number) else {
234 // Closed or edited between the scan and now. Not an error: the
235 // queue moved, which is the normal case for a human-authored one.
236 return Ok(Intent::Gone);
237 };
238
239 let tiered = resolve_tier(
240 raw,
241 self.oracle.as_ref(),
242 &self.local_signatures,
243 std::time::SystemTime::now(),
244 );
245 let tier = tiered.tier();
246 match tiered.seed_session(std::time::SystemTime::now()) {
247 Ok(seed) => Ok(Intent::Seed(seed)),
248 Err(refusal) => {
249 // A refusal is not a failure of the loop. It is the gate doing
250 // its job, and it is logged at the tier rather than surfaced as
251 // an error the operator must act on.
252 tracing::info!(
253 repo = %item.repo,
254 number = item.number,
255 refusal = ?refusal,
256 "self-heal: item not cleared to seed a session"
257 );
258 Ok(Intent::Refused {
259 tier,
260 stale: matches!(refusal, ProvenanceRefusal::StaleTier { .. }),
261 })
262 }
263 }
264 }
265
266 fn redact(&self, text: &str) -> String {
267 self.redactor.redact(text)
268 }
269
270 async fn run_coder(
271 &self,
272 target: &HealTarget,
273 item: &Candidate,
274 seed: &SessionSeed,
275 ) -> Result<Attempt, RunFailure> {
276 self.coder.run(target, item, seed).await
277 }
278
279 async fn deliver(
280 &self,
281 target: &HealTarget,
282 item: &Candidate,
283 session_id: &str,
284 gate: &GateOutcome,
285 ) -> Result<PrDeliveryOutcome, DeliverRefusal> {
286 // The body MUST reference the issue. Coverage is what stops a later
287 // tick redoing this work once the claim expires, and it matches on that
288 // reference — so a pull request that omits it is a pull request the
289 // loop will duplicate.
290 let reference = if target.is_cross_repo() {
291 format!("{}#{}", item.repo, item.number)
292 } else {
293 format!("#{}", item.number)
294 };
295 // Redacted, for the same reason every issue comment is: the gate
296 // summary carries contract output and vendor error strings, and a pull
297 // request body is exactly as public — and exactly as unpostable after
298 // the fact — as a comment. The successful path is the one that carries
299 // the most contract output, so leaving it raw here would have leaked
300 // more than the failure path it was guarded on.
301 let body = pr_body(
302 &reference,
303 &self.redactor.redact(&gate.summary()),
304 &self.panel,
305 );
306
307 self.coder.deliver(target, item, session_id, &body).await
308 }
309
310 async fn abandon(&self, session_id: &str) {
311 self.coder.abandon(session_id).await;
312 }
313
314 async fn comment(&self, item: &Candidate, text: &str) -> Result<(), String> {
315 let args: Vec<String> = vec![
316 "issue".into(),
317 "comment".into(),
318 item.number.to_string(),
319 "--repo".into(),
320 item.repo.clone(),
321 "--body".into(),
322 text.to_string(),
323 ];
324 super::merge::gh(Path::new("."), &args)
325 .map(|_| ())
326 .map_err(|e| format!("comment on {}#{}: {}", item.repo, item.number, e.message))
327 }
328
329 fn now_ms(&self) -> u64 {
330 std::time::SystemTime::now()
331 .duration_since(std::time::UNIX_EPOCH)
332 .map(|d| d.as_millis() as u64)
333 .unwrap_or(0)
334 }
335}
336
337/// Turn a panel's raw answers into verdicts, refusing to guess.
338///
339/// An answer that cannot be read as PASS or FAIL is [`Unreachable`], never a
340/// pass. There are already two PASS/FAIL parsers in this workspace
341/// (`car-agents::verifier` and `car-multi`'s adversarial review, which has an
342/// explicit inconclusive path); this is the third place that has to make the
343/// same call, and the only safe reading of "I could not tell what the reviewer
344/// said" is that the reviewer did not approve.
345pub fn parse_verdict(model: &str, answer: &str) -> Result<Verdict, Unreachable> {
346 // The FIRST verdict token, not a substring scan of the whole answer.
347 //
348 // Scanning for "PASS" and "FAIL" anywhere and calling both-or-neither
349 // unreadable looks conservative and is not: the prompt asks for a verdict
350 // followed by a sentence of reason, and a sentence justifying a pass
351 // routinely contains the word "fail". "PASS — nothing here would fail the
352 // existing checks" matched both and became `Unreachable`. Two of those on
353 // a panel of three is `PanelIncomplete`, which costs a full coder session
354 // and a backoff — and every test of this function used hand-written
355 // strings shaped to avoid the collision, so the whole suite was blind to
356 // it.
357 //
358 // A verdict is a word, so read it as one: scan tokens in order and take
359 // the first that IS "pass" or "fail", ignoring surrounding punctuation.
360 // Anything before it is a preamble; anything after is the reason.
361 let verdict = answer
362 .split(|c: char| c.is_whitespace())
363 .map(|t| t.trim_matches(|c: char| !c.is_ascii_alphabetic()))
364 .find_map(|t| match t.to_ascii_uppercase().as_str() {
365 "PASS" => Some(true),
366 "FAIL" => Some(false),
367 _ => None,
368 });
369
370 // No verdict token at all is unreadable — never a pass. There are two other
371 // PASS/FAIL parsers in this workspace and this is the third place that has
372 // to make the same call; the only safe reading of "I could not tell what
373 // the reviewer said" is that the reviewer did not approve.
374 let Some(pass) = verdict else {
375 return Err(Unreachable {
376 model: model.to_string(),
377 error: "reviewer answer contained neither a PASS nor a FAIL verdict".into(),
378 });
379 };
380 Ok(Verdict {
381 model: model.to_string(),
382 pass,
383 reason: answer.trim().chars().take(280).collect(),
384 })
385}
386
387#[cfg(test)]
388mod tests {
389
390 use super::super::heal_review::PanelSeat;
391
392 fn seat(model: &str, vendor: Option<&str>) -> PanelSeat {
393 PanelSeat {
394 model: model.to_string(),
395 vendor: vendor.map(str::to_string),
396 }
397 }
398
399 /// The verdict and what produced it travel together.
400 ///
401 /// car#1263: the body said "approved by 3/3 reviewers" and nothing else, so
402 /// three seats served by one vendor were indistinguishable from three
403 /// independent ones — to the human using that number to decide how closely
404 /// to read the change.
405 #[test]
406 fn the_pull_request_body_carries_the_panel_and_its_caveat() {
407 let body = pr_body(
408 "#42",
409 "approved by 3/3 reviewers, checks green",
410 &[
411 seat("gpt-5.5", Some("openai")),
412 seat("gpt-5.4", Some("openai")),
413 seat("gpt-5.6-sol", Some("openai")),
414 ],
415 );
416 // The reference coverage matches on is still there.
417 assert!(body.contains("#42"), "{body}");
418 assert!(body.contains("approved by 3/3 reviewers"), "{body}");
419 // ...and so is who approved it, and why that is worth less than it looks.
420 assert!(body.contains("gpt-5.5 (openai)"), "{body}");
421 assert!(body.contains("not demonstrably independent"), "{body}");
422 assert!(body.contains("openai serves 3 of the 3 seats"), "{body}");
423 }
424
425 /// A genuinely independent panel is reported without a caveat — the warning
426 /// has to mean something when it appears.
427 #[test]
428 fn an_independent_panel_gets_seats_but_no_caveat() {
429 let body = pr_body(
430 "acme/repo#7",
431 "approved by 2/3 reviewers, checks green",
432 &[
433 seat("gpt-5.5", Some("openai")),
434 seat("claude-opus-5", Some("anthropic")),
435 seat("gemini-3.1", Some("google")),
436 ],
437 );
438 assert!(body.contains("acme/repo#7"), "{body}");
439 assert!(body.contains("claude-opus-5 (anthropic)"), "{body}");
440 assert!(!body.contains("not demonstrably independent"), "{body}");
441 }
442
443 /// An empty panel must not add a stray blank section — the loop refuses a
444 /// panel of zero anyway, so this is only about the rendering.
445 #[test]
446 fn no_panel_adds_no_panel_section() {
447 let body = pr_body("#1", "approved by 1/1 reviewers, checks green", &[]);
448 assert!(!body.contains("Panel:"), "{body}");
449 assert!(!body.contains("not demonstrably independent"), "{body}");
450 }
451
452 use super::*;
453
454 #[test]
455 fn a_clear_pass_and_a_clear_fail_are_read() {
456 assert!(parse_verdict("a", "PASS — scoped correctly").unwrap().pass);
457 assert!(
458 !parse_verdict("a", "FAIL: changes unrelated code")
459 .unwrap()
460 .pass
461 );
462 }
463
464 #[test]
465 fn an_unreadable_answer_is_unreachable_never_a_pass() {
466 // The safe reading of "I could not tell what the reviewer said" is that
467 // the reviewer did not approve.
468 assert!(parse_verdict("a", "I think it's probably fine?").is_err());
469 assert!(parse_verdict("a", "").is_err());
470 }
471
472 #[test]
473 fn a_reason_mentioning_the_other_word_does_not_destroy_the_verdict() {
474 // THE regression, and the likeliest thing to have gone wrong on the
475 // first real panel: the prompt asks for a verdict plus a sentence, and
476 // a sentence justifying a pass routinely says "fail".
477 let v = parse_verdict("a", "PASS — nothing here would fail the existing checks").unwrap();
478 assert!(v.pass);
479 let v = parse_verdict("a", "FAIL — this would not pass review by a human").unwrap();
480 assert!(!v.pass);
481 }
482
483 #[test]
484 fn the_first_verdict_token_wins() {
485 // A reviewer that leads with FAIL and later muses about passing has
486 // given a FAIL. Reading the last token instead would let a trailing
487 // aside overturn the verdict.
488 let v = parse_verdict("a", "FAIL. It might pass once the test is added.").unwrap();
489 assert!(!v.pass);
490 }
491
492 #[test]
493 fn a_verdict_inside_prose_is_still_read() {
494 // Models do not reliably lead with the token, and refusing an answer
495 // that plainly contains one would fail the item for formatting.
496 assert!(
497 parse_verdict("a", "My verdict: PASS, it matches the intent")
498 .unwrap()
499 .pass
500 );
501 }
502
503 #[test]
504 fn a_word_containing_pass_is_not_a_verdict() {
505 // `contains` matched "passed", "passes", "bypass", "compass". A verdict
506 // is a word.
507 assert!(parse_verdict("a", "the tests bypassed the new branch").is_err());
508 assert!(parse_verdict("a", "this passes muster").is_err());
509 }
510
511 #[test]
512 fn a_reason_is_bounded() {
513 let long = "PASS ".to_string() + &"x".repeat(1000);
514 let v = parse_verdict("a", &long).unwrap();
515 assert!(
516 v.reason.len() <= 280,
517 "an unbounded reason reaches a tracker"
518 );
519 }
520
521 #[test]
522 fn scanned_candidates_are_untiered_not_public() {
523 // The regression this pins: `Public` is a RESOLVED "untrusted", and
524 // `heal_select::ineligible` rejects a resolved denial before it looks
525 // at anything else — so building candidates as `Public` made the live
526 // loop skip every issue as `UntrustedAuthor` and select nothing, ever.
527 let issues = vec![crate::coder::provenance::RawIssue::new(
528 "acme/widgets",
529 7,
530 "maintainer",
531 "a title",
532 "a body",
533 vec!["self-heal".to_string()],
534 1,
535 )];
536 let target = HealTarget {
537 repo: "acme/widgets".into(),
538 fix_repo: None,
539 checkout: Some(crate::coder::heal_intake::Checkout::Local("/tmp/x".into())),
540 label: "self-heal".into(),
541 base: "main".into(),
542 };
543 let out = untiered_candidates(&target, &issues, &[]);
544 assert_eq!(out.len(), 1);
545 assert_eq!(out[0].tier, None, "a scanned candidate is untiered");
546 assert!(out[0].labelled, "the cheap checks still ran");
547 }
548
549 #[test]
550 fn public_still_authorises_nothing_where_it_is_resolved() {
551 // The tier itself is unchanged; only the encoding of "not yet asked".
552 assert!(!crate::coder::provenance::ProvenanceTier::Public.may_seed_session());
553 assert!(!crate::coder::provenance::ProvenanceTier::Public.may_source_contract());
554 }
555}