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}