Skip to main content

car_server_core/coder/
heal_select.rs

1//! Choosing what the self-healing loop works on next.
2//!
3//! Pure: no I/O, no network, no model, no clock — `now_ms` is an argument. The
4//! decision is the part that must be reviewable and reproducible, so it is
5//! separated from the reads that feed it and the actions that follow, in the
6//! same shape `car-selfheal` already uses for detection.
7//!
8//! ## Deterministic, and deliberately dull
9//!
10//! Nothing here asks a model anything. Priority is a signal humans already
11//! express through labels; inferring it would add a failure mode and buy
12//! nothing. Ordering is oldest-first, which is a total order over a `u64` and
13//! therefore stable across ticks — two daemons reading the same queue reach the
14//! same answer, and a restart resumes where it left off rather than re-picking
15//! whatever a model found most interesting this time.
16//!
17//! ## What "eligible" means, and why each clause exists
18//!
19//! Every clause below removes a way for the loop to do something a human did
20//! not ask for:
21//!
22//! - **Authorised by provenance.** A public author can never seed a session.
23//!   The label narrows; the tier authorises. See [`super::provenance`].
24//! - **Opted in by label.** Silence is not consent: an unlabelled item is
25//!   invisible.
26//! - **Writable target.** A target with no checkout is watch-only.
27//! - **Not already claimed.** Unless the claim has expired, which is how a
28//!   crashed tick releases its item instead of stranding it forever.
29//! - **Not already covered.** An open pull request referencing the issue means
30//!   the work exists; a second one is noise a human has to close.
31
32use std::collections::HashMap;
33
34use super::heal_claims::Attempts;
35use super::heal_intake::{ChecksState, HealTarget, RawPullRequest};
36use super::provenance::ProvenanceTier;
37
38/// How long a claim holds an item before another tick may take it.
39///
40/// Long enough that an ordinary coder session finishes inside it, short enough
41/// that a crashed daemon does not park an issue for a day. A claim is a
42/// courtesy between ticks, not a lock: the cost of getting this wrong in one
43/// direction is duplicated work, and in the other a queue that silently stops
44/// moving. Duplicated work is the cheaper mistake, so this errs short.
45pub const CLAIM_TTL_MS: u64 = 90 * 60 * 1000;
46
47/// One thing the loop could work on, reduced to what the decision needs.
48///
49/// Deliberately not a GitHub type. The selection rules are about authorisation,
50/// opt-in and duplication — none of which need a body, and all of which are
51/// easier to reason about when the body is not reachable. Building one of these
52/// is where tracker text stops and decisions begin.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Candidate {
55    pub repo: String,
56    pub number: u64,
57    /// The provenance tier the author resolved to, or `None` when nobody has
58    /// resolved it yet.
59    ///
60    /// `None` is a real state, not a default to be filled in: resolving costs a
61    /// `gh api` call per item and goes stale in two minutes, so the live path
62    /// scans cheaply and tiers only the item it actually reaches. See
63    /// [`ineligible`] for why deferring does not widen what may run.
64    pub tier: Option<ProvenanceTier>,
65    /// Whether the target's opt-in label is present.
66    pub labelled: bool,
67    /// Unix ms the item was created. The sort key.
68    pub created_ms: u64,
69    pub kind: CandidateKind,
70}
71
72/// What sort of work an item represents.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum CandidateKind {
75    /// An open issue: fix it and open a pull request.
76    Issue,
77    /// An open pull request whose checks are failing or whose reviewers asked
78    /// for changes.
79    FailingPullRequest,
80}
81
82/// An outstanding claim on an item.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Claim {
85    pub run_id: String,
86    pub claimed_ms: u64,
87}
88
89impl Claim {
90    fn is_live(&self, now_ms: u64) -> bool {
91        now_ms.saturating_sub(self.claimed_ms) < CLAIM_TTL_MS
92    }
93}
94
95/// Why an item was passed over. Recorded rather than dropped, because "the
96/// queue looks full but the loop does nothing" is otherwise unexplainable
97/// without attaching a debugger.
98#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
99#[serde(tag = "skip", rename_all = "snake_case")]
100pub enum Skip {
101    /// The author may not seed a coder session at this tier.
102    UntrustedAuthor { tier: ProvenanceTier },
103    /// The opt-in label is absent.
104    NotLabelled,
105    /// The target has no checkout, so nothing can be written.
106    WatchOnly,
107    /// Another tick holds it.
108    Claimed { run_id: String },
109    /// An open pull request already references this issue.
110    AlreadyCovered { pr_number: u64 },
111    /// The item's text could not be trust-cleared into a session seed.
112    ///
113    /// Distinct from [`Self::UntrustedAuthor`], which is about the author's
114    /// tier. An item can pass the tier gate and still fail clearance — most
115    /// often because the tier was resolved too long ago to rely on. Reporting
116    /// that as an authorisation failure sends an operator to check permissions
117    /// when the remedy is to retry.
118    IntentNotCleared,
119    /// Closed or edited between the scan and the moment it was reached.
120    ///
121    /// Not a failure of anything: a human-authored queue moves while the loop
122    /// reads it. Distinct from every other variant because it needs no backoff
123    /// — a later tick will not see this item at all.
124    Gone,
125    /// Attempted before and failed; waiting out its backoff, or exhausted.
126    RecentlyFailed {
127        attempts: u32,
128        next_eligible_ms: Option<u64>,
129        reason: String,
130    },
131}
132
133/// One item, with the reason it was passed over.
134#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
135pub struct SkippedItem {
136    pub repo: String,
137    pub number: u64,
138    pub reason: Skip,
139}
140
141/// What one pass over the queue decided.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct Selection {
144    /// The item to work on, if any is eligible.
145    pub chosen: Option<Candidate>,
146    /// Everything passed over, and why.
147    pub skipped: Vec<SkippedItem>,
148}
149
150/// Pick at most one item to work on.
151///
152/// `open_prs` is every open pull request on the same repository — used only to
153/// answer "is this issue already covered". `claims` is keyed `repo#number`.
154///
155/// Returns `None` for `chosen` when nothing is eligible, which is the loop's
156/// **steady state**: the queue is human-authored, so an empty or fully-handled
157/// queue means there is nothing to do, not that something is wrong.
158pub fn select(
159    target: &HealTarget,
160    candidates: &[Candidate],
161    open_prs: &[RawPullRequest],
162    claims: &HashMap<String, Claim>,
163    attempts: &HashMap<String, Attempts>,
164    now_ms: u64,
165) -> Selection {
166    let mut skipped = Vec::new();
167    let mut eligible: Vec<&Candidate> = Vec::new();
168
169    for c in candidates {
170        if let Some(reason) = ineligible(target, c, open_prs, claims, attempts, now_ms) {
171            skipped.push(SkippedItem {
172                repo: c.repo.clone(),
173                number: c.number,
174                reason,
175            });
176            continue;
177        }
178        eligible.push(c);
179    }
180
181    // Oldest first, then by number, so the order is total even when two items
182    // share a timestamp — otherwise two daemons could disagree about "oldest"
183    // and both take work.
184    eligible.sort_by_key(|c| (c.created_ms, c.number));
185
186    Selection {
187        chosen: eligible.first().map(|c| (*c).clone()),
188        skipped,
189    }
190}
191
192fn ineligible(
193    target: &HealTarget,
194    c: &Candidate,
195    open_prs: &[RawPullRequest],
196    claims: &HashMap<String, Claim>,
197    attempts: &HashMap<String, Attempts>,
198    now_ms: u64,
199) -> Option<Skip> {
200    // Authorisation first, when it is known. Every later clause is about
201    // whether there is work to do; this one is about whether we are allowed to
202    // do it at all, and a *resolved* denial must not be reachable past a
203    // cheaper check that happens to return early.
204    //
205    // `None` is not a denial and not a grant — it is "nobody has asked yet".
206    // Resolving a tier is a `gh api` call per item and deliberately memoizes
207    // nothing, so tiering all 200 scanned issues up front would be 200 calls
208    // per tick per target AND leave the chosen item's tier minutes stale by the
209    // time the gate checks its freshness. `heal_live` therefore builds
210    // candidates cheaply and resolves the tier of the one that is actually
211    // reached, in `intent_for`.
212    //
213    // This used to be `ProvenanceTier::Public`, on the reasoning that the
214    // absence of a claim about the author should authorise nothing. The
215    // reasoning is right and the encoding was wrong: `Public` is a RESOLVED
216    // tier meaning "this author is untrusted", so this clause rejected it
217    // first, every candidate on the live path was skipped as
218    // `UntrustedAuthor`, and the loop could never select anything at all. The
219    // symptom was a permanently idle loop that reported a reason which was not
220    // true of any of those authors.
221    //
222    // Deferring is safe because it does not widen what may run: `intent_for`
223    // resolves the tier for real, on fresh permission data, and returns no seed
224    // when it refuses — which `tick` reports as `IntentNotCleared`. Nothing
225    // starts a session on an unresolved tier; the authorisation moved, it did
226    // not weaken.
227    if let Some(tier) = c.tier {
228        if !tier.may_seed_session() {
229            return Some(Skip::UntrustedAuthor { tier });
230        }
231    }
232    if !c.labelled {
233        return Some(Skip::NotLabelled);
234    }
235    if !target.can_write() {
236        return Some(Skip::WatchOnly);
237    }
238    if let Some(claim) = claims.get(&claim_key(&c.repo, c.number)) {
239        if claim.is_live(now_ms) {
240            return Some(Skip::Claimed {
241                run_id: claim.run_id.clone(),
242            });
243        }
244    }
245    // The backoff, and the only place it is READ.
246    //
247    // `record_failure` wrote into this map and nothing consulted it, so the
248    // exponential backoff, `MAX_ATTEMPTS`, and this whole variant were dead at
249    // runtime: a failing item was released with its failure recorded and then
250    // re-selected on the very next tick, which is exactly the metronome the
251    // ledger exists to stop. Selection is oldest-first with no other memory, so
252    // if this clause does not fire, nothing moves the sweep past a item that
253    // keeps failing.
254    if let Some(a) = attempts.get(&claim_key(&c.repo, c.number)) {
255        if !a.ready(now_ms) {
256            return Some(Skip::RecentlyFailed {
257                attempts: a.count,
258                next_eligible_ms: a.next_eligible_ms(),
259                reason: a.last_reason.clone(),
260            });
261        }
262    }
263    if c.kind == CandidateKind::Issue {
264        // QUALIFIED by the tracker repo when the fix lands elsewhere. On a
265        // `fix_repo` target, a bare `#123` in a pull request on the source repo
266        // means issue 123 *of that repo* — reading it as the tracker's issue
267        // 123 marks an unrelated item covered, permanently and silently, and
268        // that item never gets worked. The qualified form existed with its own
269        // doc naming this hazard and had zero production call sites. The loop's
270        // own pull-request bodies write `owner/repo#N`, which matches under
271        // both readings, so self-coverage is unaffected.
272        let tracker = target.is_cross_repo().then_some(target.repo.as_str());
273        if let Some(pr) = open_prs.iter().find(|pr| pr.references(tracker, c.number)) {
274            return Some(Skip::AlreadyCovered {
275                pr_number: pr.number(),
276            });
277        }
278    }
279    None
280}
281
282/// The key a claim is stored under. One spelling, because a claim written under
283/// one form and read under another is a claim that never holds.
284pub fn claim_key(repo: &str, number: u64) -> String {
285    format!("{repo}#{number}")
286}
287
288/// Whether an open pull request is itself work: red checks, or reviewers who
289/// asked for changes.
290///
291/// A draft is excluded — it is explicitly unfinished, and its author has not
292/// asked anyone to look yet. Pending checks are excluded too: a pull request
293/// whose checks have not reported is not failing.
294pub fn pr_needs_work(pr: &RawPullRequest) -> bool {
295    if pr.is_draft() {
296        return false;
297    }
298    matches!(pr.checks(), ChecksState::Failing) || pr.changes_requested()
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::coder::heal_intake::Checkout;
305
306    fn target() -> HealTarget {
307        HealTarget {
308            repo: "acme/widgets".into(),
309            fix_repo: None,
310            checkout: Some(Checkout::Project("widgets".into())),
311            label: "self-heal".into(),
312            base: "main".into(),
313        }
314    }
315
316    /// The live path's shape: scanned but not yet tiered.
317    fn untiered(number: u64, labelled: bool) -> Candidate {
318        Candidate {
319            repo: "acme/widgets".into(),
320            number,
321            tier: None,
322            labelled,
323            created_ms: number,
324            kind: CandidateKind::Issue,
325        }
326    }
327
328    #[test]
329    fn an_untiered_candidate_is_not_rejected_as_untrusted() {
330        // THE regression. `heal_live` scans cheaply and tiers only the item it
331        // reaches, so every candidate arrives here untiered. Encoding that as
332        // `ProvenanceTier::Public` — a resolved "untrusted" — made this
333        // function reject all of them before it looked at a single label, and
334        // the live loop selected nothing, ever, while reporting a reason that
335        // was not true of any of those authors.
336        let out = select(
337            &target(),
338            &[untiered(7, true)],
339            &[],
340            &HashMap::new(),
341            &HashMap::new(),
342            0,
343        );
344        assert_eq!(out.chosen.map(|c| c.number), Some(7));
345        assert!(out.skipped.is_empty());
346    }
347
348    #[test]
349    fn an_untiered_candidate_is_still_subject_to_every_other_check() {
350        // Deferring the tier must not defer anything else — an unlabelled item
351        // is invisible whether or not its author has been resolved.
352        let out = select(
353            &target(),
354            &[untiered(7, false)],
355            &[],
356            &HashMap::new(),
357            &HashMap::new(),
358            0,
359        );
360        assert!(out.chosen.is_none());
361        assert_eq!(out.skipped[0].reason, Skip::NotLabelled);
362    }
363
364    #[test]
365    fn a_resolved_denial_still_wins_over_every_cheaper_check() {
366        // The original ordering property, unchanged: a KNOWN-untrusted author
367        // is refused on authorisation, not on some cheaper clause that happens
368        // to fire first and would report the wrong reason.
369        let mut c = candidate(7, 1, ProvenanceTier::Public);
370        c.labelled = false;
371        let out = select(&target(), &[c], &[], &HashMap::new(), &HashMap::new(), 0);
372        assert_eq!(
373            out.skipped[0].reason,
374            Skip::UntrustedAuthor {
375                tier: ProvenanceTier::Public
376            }
377        );
378    }
379
380    fn candidate(number: u64, created_ms: u64, tier: ProvenanceTier) -> Candidate {
381        Candidate {
382            repo: "acme/widgets".into(),
383            number,
384            tier: Some(tier),
385            labelled: true,
386            created_ms,
387            kind: CandidateKind::Issue,
388        }
389    }
390
391    fn pr_with(number: u64, body: &str) -> RawPullRequest {
392        RawPullRequest::new(
393            "acme/widgets",
394            number,
395            "someone",
396            "a pull request",
397            body,
398            vec![],
399            ChecksState::Passing,
400            "",
401            false,
402        )
403    }
404
405    #[test]
406    fn an_empty_queue_selects_nothing() {
407        let s = select(&target(), &[], &[], &HashMap::new(), &HashMap::new(), 0);
408        assert_eq!(s.chosen, None, "idle is the steady state, not an error");
409        assert!(s.skipped.is_empty());
410    }
411
412    #[test]
413    fn a_public_author_can_never_seed_a_session() {
414        // The sharp one: `car-releases` is public, so this is the difference
415        // between a loop and an arbitrary-code-execution surface.
416        let c = candidate(1, 0, ProvenanceTier::Public);
417        let s = select(&target(), &[c], &[], &HashMap::new(), &HashMap::new(), 0);
418        assert_eq!(s.chosen, None);
419        assert_eq!(
420            s.skipped[0].reason,
421            Skip::UntrustedAuthor {
422                tier: ProvenanceTier::Public
423            }
424        );
425    }
426
427    #[test]
428    fn authorisation_is_checked_before_anything_cheaper() {
429        // An unlabelled PUBLIC issue must report the tier, not the label:
430        // otherwise a reader concludes "add the label and it will run", which
431        // is false and dangerously reassuring.
432        let mut c = candidate(1, 0, ProvenanceTier::Public);
433        c.labelled = false;
434        let s = select(&target(), &[c], &[], &HashMap::new(), &HashMap::new(), 0);
435        assert!(matches!(s.skipped[0].reason, Skip::UntrustedAuthor { .. }));
436    }
437
438    #[test]
439    fn an_unlabelled_item_is_invisible() {
440        let mut c = candidate(1, 0, ProvenanceTier::Maintainer);
441        c.labelled = false;
442        let s = select(&target(), &[c], &[], &HashMap::new(), &HashMap::new(), 0);
443        assert_eq!(s.chosen, None);
444        assert_eq!(s.skipped[0].reason, Skip::NotLabelled);
445    }
446
447    #[test]
448    fn a_maintainer_issue_is_eligible() {
449        let c = candidate(1, 0, ProvenanceTier::Maintainer);
450        let s = select(
451            &target(),
452            std::slice::from_ref(&c),
453            &[],
454            &HashMap::new(),
455            &HashMap::new(),
456            0,
457        );
458        assert_eq!(s.chosen, Some(c));
459    }
460
461    #[test]
462    fn a_watch_only_target_selects_nothing() {
463        let mut t = target();
464        t.checkout = None;
465        let c = candidate(1, 0, ProvenanceTier::Maintainer);
466        let s = select(&t, &[c], &[], &HashMap::new(), &HashMap::new(), 0);
467        assert_eq!(s.chosen, None);
468        assert_eq!(s.skipped[0].reason, Skip::WatchOnly);
469    }
470
471    #[test]
472    fn oldest_first_and_stable_when_timestamps_tie() {
473        let a = candidate(9, 100, ProvenanceTier::Maintainer);
474        let b = candidate(2, 100, ProvenanceTier::Maintainer);
475        let older = candidate(5, 50, ProvenanceTier::Maintainer);
476        let s = select(
477            &target(),
478            &[a, b.clone(), older.clone()],
479            &[],
480            &HashMap::new(),
481            &HashMap::new(),
482            0,
483        );
484        assert_eq!(s.chosen, Some(older), "oldest wins");
485
486        // With the oldest gone, the tie between 9 and 2 resolves by number, so
487        // two daemons reading the same queue cannot disagree.
488        let s2 = select(
489            &target(),
490            &[candidate(9, 100, ProvenanceTier::Maintainer), b.clone()],
491            &[],
492            &HashMap::new(),
493            &HashMap::new(),
494            0,
495        );
496        assert_eq!(s2.chosen, Some(b));
497    }
498
499    #[test]
500    fn a_live_claim_holds_the_item() {
501        let c = candidate(1, 0, ProvenanceTier::Maintainer);
502        let mut claims = HashMap::new();
503        claims.insert(
504            claim_key("acme/widgets", 1),
505            Claim {
506                run_id: "run-a".into(),
507                claimed_ms: 1_000,
508            },
509        );
510        let s = select(
511            &target(),
512            &[c],
513            &[],
514            &claims,
515            &HashMap::new(),
516            1_000 + CLAIM_TTL_MS - 1,
517        );
518        assert_eq!(s.chosen, None);
519        assert_eq!(
520            s.skipped[0].reason,
521            Skip::Claimed {
522                run_id: "run-a".into()
523            }
524        );
525    }
526
527    #[test]
528    fn an_expired_claim_releases_the_item() {
529        // How a crashed tick gives its work back instead of parking it.
530        let c = candidate(1, 0, ProvenanceTier::Maintainer);
531        let mut claims = HashMap::new();
532        claims.insert(
533            claim_key("acme/widgets", 1),
534            Claim {
535                run_id: "dead-run".into(),
536                claimed_ms: 1_000,
537            },
538        );
539        let s = select(
540            &target(),
541            std::slice::from_ref(&c),
542            &[],
543            &claims,
544            &HashMap::new(),
545            1_000 + CLAIM_TTL_MS,
546        );
547        assert_eq!(s.chosen, Some(c));
548    }
549
550    #[test]
551    fn an_issue_already_covered_by_a_pull_request_is_skipped() {
552        let c = candidate(42, 0, ProvenanceTier::Maintainer);
553        let prs = vec![pr_with(7, "fixes #42")];
554        let s = select(&target(), &[c], &prs, &HashMap::new(), &HashMap::new(), 0);
555        assert_eq!(s.chosen, None);
556        assert_eq!(s.skipped[0].reason, Skip::AlreadyCovered { pr_number: 7 });
557    }
558
559    #[test]
560    fn a_pull_request_for_a_different_issue_does_not_cover_this_one() {
561        let c = candidate(42, 0, ProvenanceTier::Maintainer);
562        let prs = vec![pr_with(7, "fixes #43")];
563        let s = select(
564            &target(),
565            std::slice::from_ref(&c),
566            &prs,
567            &HashMap::new(),
568            &HashMap::new(),
569            0,
570        );
571        assert_eq!(s.chosen, Some(c));
572    }
573
574    #[test]
575    fn coverage_does_not_apply_to_a_failing_pull_request() {
576        // A failing PR is not "covered by" itself; that clause is about issues.
577        let mut c = candidate(7, 0, ProvenanceTier::Maintainer);
578        c.kind = CandidateKind::FailingPullRequest;
579        let prs = vec![pr_with(7, "this is the pull request itself, #7")];
580        let s = select(
581            &target(),
582            std::slice::from_ref(&c),
583            &prs,
584            &HashMap::new(),
585            &HashMap::new(),
586            0,
587        );
588        assert_eq!(s.chosen, Some(c));
589    }
590
591    #[test]
592    fn a_draft_is_never_work() {
593        let pr = RawPullRequest::new(
594            "acme/widgets",
595            1,
596            "someone",
597            "wip",
598            "",
599            vec![],
600            ChecksState::Failing,
601            "",
602            true,
603        );
604        assert!(!pr_needs_work(&pr), "a draft has not asked for review");
605    }
606
607    #[test]
608    fn pending_checks_are_not_failure() {
609        let pr = RawPullRequest::new(
610            "acme/widgets",
611            1,
612            "someone",
613            "t",
614            "",
615            vec![],
616            ChecksState::Pending,
617            "",
618            false,
619        );
620        assert!(!pr_needs_work(&pr));
621    }
622
623    #[test]
624    fn failing_checks_or_requested_changes_are_work() {
625        let failing = RawPullRequest::new(
626            "acme/widgets",
627            1,
628            "s",
629            "t",
630            "",
631            vec![],
632            ChecksState::Failing,
633            "",
634            false,
635        );
636        assert!(pr_needs_work(&failing));
637
638        let changes = RawPullRequest::new(
639            "acme/widgets",
640            2,
641            "s",
642            "t",
643            "",
644            vec![],
645            ChecksState::Passing,
646            "CHANGES_REQUESTED",
647            false,
648        );
649        assert!(pr_needs_work(&changes));
650    }
651
652    #[test]
653    fn every_skip_is_recorded_so_an_idle_loop_can_be_explained() {
654        let mut public = candidate(1, 0, ProvenanceTier::Public);
655        public.labelled = true;
656        let mut unlabelled = candidate(2, 0, ProvenanceTier::Maintainer);
657        unlabelled.labelled = false;
658        let s = select(
659            &target(),
660            &[public, unlabelled],
661            &[],
662            &HashMap::new(),
663            &HashMap::new(),
664            0,
665        );
666        assert_eq!(s.chosen, None);
667        assert_eq!(s.skipped.len(), 2, "silence would be unexplainable");
668    }
669}