Skip to main content

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}