Skip to main content

car_server_core/coder/
heal_tick.rs

1//! One pass of the self-healing loop.
2//!
3//! T5 of `docs/proposals/self-healing-issue-loop.md`. Composes the pieces:
4//! read → select → claim → hand to the coder → gate → open a pull request, or
5//! give the item back.
6//!
7//! ## At most one item, then exit
8//!
9//! A tick is not a loop. It takes one item and returns, and the *scheduler*
10//! decides when to run again. That is what makes the thing interruptible: a
11//! daemon restart loses at most one in-flight item, whose claim then expires,
12//! rather than a long-running process holding an unbounded amount of work.
13//!
14//! ## Idle is the steady state
15//!
16//! The queue is human-authored. A tick that finds nothing eligible has not
17//! failed — humans control what gets labelled, and most of the time the honest
18//! answer is that there is nothing to do. [`TickOutcome::Idle`] carries the
19//! skip reasons so an operator can tell "nothing eligible" from "everything was
20//! skipped for a reason I did not expect", which is the difference between a
21//! quiet loop and a broken one.
22//!
23//! ## The loop never decides an issue is fixed
24//!
25//! It opens a pull request referencing the item, and stops. The item stays open
26//! until a human closes it through the normal merge path. So there is no "am I
27//! done" judgement for a model to get wrong — termination is external, by
28//! construction.
29
30use std::sync::Arc;
31
32use super::heal_claims::ClaimStore;
33use super::heal_gate::{decide, GateOutcome, Unreachable, Verdict};
34use super::heal_intake::{Checkout, HealTarget};
35use super::heal_select::{select, Candidate, Selection, SkippedItem};
36use super::merge::{CiSummary, PrDeliveryOutcome};
37use super::provenance::{ProvenanceTier, SessionSeed};
38
39/// What resolving an item's provenance produced.
40///
41/// Three outcomes, not two, because `Option` conflated states whose correct
42/// handling differs. An item that vanished between the scan and now needs no
43/// backoff — it is gone from the queue and a later tick will not see it. An
44/// item whose author the tier gate REFUSED is still in the queue, still the
45/// oldest, and still labelled, so without a recorded failure selection picks it
46/// again on every tick and halts the sweep there forever — starving everything
47/// behind it. That path became reachable the moment selection stopped
48/// rejecting untiered candidates outright.
49#[derive(Debug)]
50pub enum Intent {
51    /// Cleared. The text may seed a coder session.
52    Seed(SessionSeed),
53    /// Closed or edited between the scan and now. The normal case for a
54    /// human-authored queue, and not a failure of anything.
55    Gone,
56    /// The provenance gate said no.
57    ///
58    /// `stale` separates the two refusals, because their remedies differ and
59    /// reporting one as the other sends an operator to the wrong place: a tier
60    /// resolved too long ago is fixed by retrying, an untrusted author is not
61    /// fixed by anything the operator does to this daemon.
62    Refused { tier: ProvenanceTier, stale: bool },
63}
64
65/// A coder run that failed, and the session it left behind.
66///
67/// The session id is the load-bearing half. `run` can fail *after* the session
68/// has reached `NeedsApproval` — no worktree at approval, an unreadable diff, a
69/// green contract over an unchanged worktree — and a plain `String` gave the
70/// caller no way to close that session out. Each one leaked a git worktree
71/// registered in the operator's own repository, with its task handle already
72/// taken so `coder.cancel` could not reach it either, and the reaper that would
73/// have swept them was deleted on the strength of the invariant these paths
74/// break.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct RunFailure {
77    pub detail: String,
78    /// `None` only when the failure happened before a session existed.
79    pub session_id: Option<String>,
80    /// The daemon configuration prevented any model from attempting the item.
81    /// Such a failure must not create item backoff or a public issue comment.
82    pub configuration: bool,
83}
84
85impl RunFailure {
86    /// A failure with no session behind it — nothing to close out.
87    pub fn early(detail: impl Into<String>) -> Self {
88        Self {
89            detail: detail.into(),
90            session_id: None,
91            configuration: false,
92        }
93    }
94
95    /// A failure that left a live session.
96    pub fn with_session(session_id: &str, detail: impl Into<String>) -> Self {
97        Self {
98            detail: detail.into(),
99            session_id: Some(session_id.to_string()),
100            configuration: false,
101        }
102    }
103
104    /// A session stopped before model dispatch because its routing constraints
105    /// leave no independent coder available.
106    pub fn configuration_with_session(session_id: &str, detail: impl Into<String>) -> Self {
107        Self {
108            detail: detail.into(),
109            session_id: Some(session_id.to_string()),
110            configuration: true,
111        }
112    }
113}
114
115/// Why a delivery stopped, and whether trying again could ever change it.
116///
117/// `permanent` is not a hint. A closed pull request on the item's delivery
118/// branch is a person saying stop, and `merge::deliver_pr_with` refuses it at
119/// preflight every time — so an ordinary failure record would spend four more
120/// coder sessions and four more panel fan-outs rediscovering the same answer.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct DeliverRefusal {
123    pub detail: String,
124    pub permanent: bool,
125}
126
127impl DeliverRefusal {
128    pub fn retriable(detail: impl Into<String>) -> Self {
129        Self {
130            detail: detail.into(),
131            permanent: false,
132        }
133    }
134    pub fn permanent(detail: impl Into<String>) -> Self {
135        Self {
136            detail: detail.into(),
137            permanent: true,
138        }
139    }
140}
141
142/// What a coder run produced: the facts a gate decides over.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Attempt {
145    /// Whether the outcome contract passed. The deterministic half.
146    pub contract_passed: bool,
147    /// What the contract reported, for the operator and the reviewers.
148    pub contract_detail: String,
149    /// How many reviewers were ASKED. The threshold derives from this, not
150    /// from how many answered.
151    pub panel_size: usize,
152    pub verdicts: Vec<Verdict>,
153    pub unreachable: Vec<Unreachable>,
154    /// The coder session that produced the work, still holding its worktree.
155    ///
156    /// A session id rather than a branch, because **nothing is published until
157    /// the gate approves**. In the interactive path `car/coder/<id>` is created
158    /// by `coder.approve_merge` — the branch exists only after a human said
159    /// yes, so it means "approved". Publishing before the panel would put
160    /// unapproved work in a namespace that already carries that meaning, and
161    /// leave one dead branch per rejection in the operator's repository. The
162    /// panel reviews the worktree diff instead, which is the same view the
163    /// human approval surface shows.
164    pub session_id: String,
165}
166
167/// What one tick did.
168#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
169#[serde(tag = "outcome", rename_all = "snake_case")]
170pub enum TickOutcome {
171    /// Nothing was eligible. The reasons are carried so a quiet loop can be
172    /// told apart from a stuck one.
173    Idle { skipped: Vec<SkippedItem> },
174    /// A pull request was opened for the item.
175    Opened {
176        repo: String,
177        number: u64,
178        pr_url: String,
179        gate: String,
180        /// Typed check-run/combined-status state for the delivered head SHA.
181        ci: CiSummary,
182        /// Human-readable form of `ci`, suitable for a delivery log.
183        delivery: String,
184    },
185    /// The item was attempted and did not clear the gate. The claim is
186    /// released; the item returns to the queue for a human or a later tick.
187    Rejected {
188        repo: String,
189        number: u64,
190        gate: String,
191    },
192    /// The tick could not run at all — the target is unusable, or a read
193    /// failed. Distinct from `Idle` because the remedy is different.
194    Failed { detail: String },
195}
196
197/// Everything a tick needs from the outside world, injected.
198///
199/// A trait rather than concrete calls so the composition — the order of claim,
200/// work, gate, release — is testable without GitHub, inference, or a worktree.
201/// That order is the part most likely to be wrong, and the part a live test
202/// would exercise least reliably.
203#[async_trait::async_trait]
204pub trait TickIo: Send + Sync {
205    /// Candidates on this target, already tiered and label-checked.
206    async fn candidates(&self, target: &HealTarget) -> Result<Vec<Candidate>, String>;
207    /// Open pull requests on this target, for the coverage check.
208    async fn open_prs(
209        &self,
210        target: &HealTarget,
211    ) -> Result<Vec<super::heal_intake::RawPullRequest>, String>;
212    /// The trust-cleared seed for this item, or `None` if it cannot be cleared.
213    ///
214    /// [`SessionSeed`] and not `String`: the newtype exists precisely so cleared
215    /// text cannot be moved around as an ordinary string, and returning a
216    /// `String` here handed that guarantee back. `None` means the item must be
217    /// skipped — never "use the raw body instead".
218    async fn intent_for(&self, item: &Candidate) -> Result<Intent, String>;
219    /// Run a coder session and return the EVIDENCE, never the verdict.
220    ///
221    /// An earlier shape had this return a finished `GateOutcome`, which meant
222    /// the injected implementation could hand back `Approved` having run no
223    /// contract and asked no reviewer — and `decide` had no callers at all. A
224    /// gate a caller can mint is not a gate. `GateOutcome` is now sealed so
225    /// only [`super::heal_gate::decide`] can build one, and this returns the
226    /// inputs to it.
227    async fn run_coder(
228        &self,
229        target: &HealTarget,
230        item: &Candidate,
231        intent: &SessionSeed,
232    ) -> Result<Attempt, RunFailure>;
233    /// Redact text bound for a public tracker.
234    ///
235    /// Gate summaries carry contract output and vendor error strings, which is
236    /// where hostnames and credentials surface. The target may be a public
237    /// repository, and a comment cannot be unposted.
238    fn redact(&self, text: &str) -> String;
239    /// Publish the work and open the pull request, as one step.
240    ///
241    /// Called only after [`decide`] approves — this is the loop's single write
242    /// to the world, and everything before it is reversible by doing nothing.
243    /// The outcome includes check-run/combined-status state read for the exact
244    /// delivered head SHA.
245    async fn deliver(
246        &self,
247        target: &HealTarget,
248        item: &Candidate,
249        session_id: &str,
250        gate: &GateOutcome,
251    ) -> Result<PrDeliveryOutcome, DeliverRefusal>;
252
253    /// Close out a session whose work will not be delivered.
254    ///
255    /// Required rather than defaulted: a coder session holds a git worktree
256    /// registered in the operator's own repository until it reaches a terminal
257    /// state, and rejection is the *expected* common outcome of a strict
258    /// majority. An implementation that forgets this leaks a worktree per
259    /// rejected item, forever, and a default no-op would let it.
260    async fn abandon(&self, session_id: &str);
261    /// Say on the item why the loop stopped, so a human is not left guessing.
262    async fn comment(&self, item: &Candidate, text: &str) -> Result<(), String>;
263    fn now_ms(&self) -> u64;
264}
265
266/// Run one tick against one target.
267/// Where the claim ledger is written, and when.
268///
269/// Injected rather than done by the caller after `tick` returns, because
270/// *after* is too late: a claim that reaches disk only once the whole tick has
271/// finished is lost if the daemon dies during the 45-minute coder session it
272/// was taken for, and the next start re-picks an item whose work is already in
273/// flight — possibly still in flight, in a detached session. `heal_service`'s
274/// own module docs state this contract; nothing implemented it.
275pub trait ClaimSink: Send + Sync {
276    fn persist(&self, claims: &ClaimStore, now_ms: u64);
277}
278
279/// A sink that writes nothing — for tests, and for a caller with no state dir.
280pub struct NoClaimSink;
281impl ClaimSink for NoClaimSink {
282    fn persist(&self, _claims: &ClaimStore, _now_ms: u64) {}
283}
284
285pub async fn tick(
286    io: &Arc<dyn TickIo>,
287    target: &HealTarget,
288    claims: &mut ClaimStore,
289    run_id: &str,
290    sink: &dyn ClaimSink,
291) -> TickOutcome {
292    // A target that cannot be written to is watch-only. Establish that before
293    // spending any API call: reading a queue we can do nothing about is pure
294    // cost.
295    if !target.can_write() {
296        // Reported, not silent: `heal_config` promises a watch-only target is a
297        // legal configuration, so an operator must be able to see that is why
298        // nothing happened.
299        return TickOutcome::Idle {
300            skipped: vec![SkippedItem {
301                repo: target.repo.clone(),
302                number: 0,
303                reason: super::heal_select::Skip::WatchOnly,
304            }],
305        };
306    }
307    if !super::heal_intake::is_valid_repo_spec(&target.repo) {
308        return TickOutcome::Failed {
309            detail: format!("`{}` is not a valid owner/name spec", target.repo),
310        };
311    }
312
313    let now = io.now_ms();
314    let candidates = match io.candidates(target).await {
315        Ok(c) => c,
316        Err(e) => return TickOutcome::Failed { detail: e },
317    };
318    let prs = match io.open_prs(target).await {
319        Ok(p) => p,
320        Err(e) => return TickOutcome::Failed { detail: e },
321    };
322
323    let Selection { chosen, skipped } = select(
324        target,
325        &candidates,
326        &prs,
327        claims.as_map(),
328        claims.attempts(),
329        now,
330    );
331    let Some(item) = chosen else {
332        return TickOutcome::Idle { skipped };
333    };
334
335    // Claim BEFORE any work. The reverse order — work, then claim — leaves a
336    // window where a second tick starts the same item, which is the whole thing
337    // claiming exists to prevent.
338    // Persisted immediately below, before the first thing that can take
339    // minutes. See `ClaimSink`.
340    if let Err(refused) = claims.claim(&item.repo, item.number, run_id, now) {
341        return TickOutcome::Idle {
342            skipped: vec![SkippedItem {
343                repo: item.repo.clone(),
344                number: item.number,
345                reason: super::heal_select::Skip::Claimed {
346                    run_id: refused.held_by,
347                },
348            }],
349        };
350    }
351    sink.persist(claims, now);
352
353    // From here every exit must release the claim, or a failed tick parks the
354    // item until its TTL expires.
355    let intent = match io.intent_for(&item).await {
356        Ok(Intent::Seed(i)) => i,
357        Ok(Intent::Gone) => {
358            // The queue moved. No failure recorded: there is nothing to back
359            // off from, and a later tick will not see this item at all.
360            claims.release(&item.repo, item.number, run_id);
361            return TickOutcome::Idle {
362                skipped: vec![SkippedItem {
363                    repo: item.repo.clone(),
364                    number: item.number,
365                    reason: super::heal_select::Skip::Gone,
366                }],
367            };
368        }
369        Ok(Intent::Refused { tier, stale }) => {
370            // Recorded, or the sweep halts here forever. This item is still in
371            // the queue, still the oldest, and still labelled, so selection
372            // picks it again on the next tick and every tick after — starving
373            // everything behind it while the operator sees only "not cleared".
374            // The backoff is what moves the queue past it.
375            // A stale tier is fixed by retrying; an untrusted author is not
376            // fixed by anything done to this daemon, so it exhausts at once
377            // rather than costing 1+1+2+4+8 hours of permission calls to reach
378            // the same answer five times.
379            if stale {
380                claims.record_failure(
381                    &item.repo,
382                    item.number,
383                    "provenance was resolved too long ago to rely on",
384                    now,
385                );
386            } else {
387                claims.record_permanent_failure(
388                    &item.repo,
389                    item.number,
390                    &format!("author is not cleared to seed a session (tier: {tier:?})"),
391                    now,
392                );
393            }
394            claims.release(&item.repo, item.number, run_id);
395            return TickOutcome::Idle {
396                skipped: vec![SkippedItem {
397                    repo: item.repo.clone(),
398                    number: item.number,
399                    // A stale tier is NOT an authorisation failure. Reporting
400                    // it as one sends an operator to check permissions when
401                    // the remedy is to retry.
402                    reason: if stale {
403                        super::heal_select::Skip::IntentNotCleared
404                    } else {
405                        super::heal_select::Skip::UntrustedAuthor { tier }
406                    },
407                }],
408            };
409        }
410        Err(e) => {
411            claims.release(&item.repo, item.number, run_id);
412            return TickOutcome::Failed { detail: e };
413        }
414    };
415
416    let attempt = match io.run_coder(target, &item, &intent).await {
417        Ok(v) => v,
418        Err(failure) => {
419            // Close the session out if one was left. `run` can fail after the
420            // session reached `NeedsApproval`, and a non-terminal session holds
421            // a git worktree in the operator's repository until something
422            // transitions it.
423            if let Some(id) = &failure.session_id {
424                io.abandon(id).await;
425            }
426            let detail = failure.detail;
427            if failure.configuration {
428                // Nothing about this item failed: the daemon excluded every
429                // review seat and had no independent coder left. Recording
430                // item backoff or posting on the issue would blame the backlog
431                // for a heal.toml condition only the operator can change.
432                claims.release(&item.repo, item.number, run_id);
433                return TickOutcome::Failed { detail };
434            }
435            claims.record_failure(&item.repo, item.number, &detail, now);
436            claims.release(&item.repo, item.number, run_id);
437            let _ = io
438                .comment(
439                    &item,
440                    &io.redact(&format!("self-heal could not run: {detail}")),
441                )
442                .await;
443            return TickOutcome::Failed { detail };
444        }
445    };
446
447    // The verdict is computed HERE, from evidence, by the one function that
448    // can build a `GateOutcome`.
449    let gate = decide(
450        attempt.contract_passed,
451        &attempt.contract_detail,
452        attempt.panel_size,
453        &attempt.verdicts,
454        &attempt.unreachable,
455    );
456
457    if !gate.approved() {
458        let summary = gate.summary();
459        // Terminal, so the RAII handle drops and the git worktree registered in
460        // the operator's repository goes with it. Before the comment, because
461        // the comment is best-effort and the worktree is not.
462        io.abandon(&attempt.session_id).await;
463        // Record the failure BEFORE releasing, or the next tick picks the same
464        // item immediately and the loop becomes a metronome.
465        claims.record_failure(&item.repo, item.number, &summary, now);
466        claims.release(&item.repo, item.number, run_id);
467        // Say why on the item itself, redacted: an unattended loop that fails
468        // silently teaches people to ignore it, and one that leaks a vendor
469        // error string into a public tracker cannot be unposted.
470        let _ = io
471            .comment(&item, &io.redact(&format!("self-heal stopped: {summary}")))
472            .await;
473        return TickOutcome::Rejected {
474            repo: item.repo.clone(),
475            number: item.number,
476            gate: summary,
477        };
478    }
479
480    match io.deliver(target, &item, &attempt.session_id, &gate).await {
481        Ok(delivered) => {
482            // The work landed. Forget prior failures so a later, unrelated
483            // failure starts from a clean backoff rather than an old count.
484            claims.clear_failures(&item.repo, item.number);
485            let delivery = delivered.delivery_report();
486            TickOutcome::Opened {
487                repo: item.repo.clone(),
488                number: item.number,
489                pr_url: delivered.pr_url,
490                gate: gate.summary(),
491                ci: delivered.ci,
492                delivery,
493            }
494        }
495        Err(refusal) => {
496            io.abandon(&attempt.session_id).await;
497            let detail = format!(
498                "gate passed but the pull request could not be opened: {}",
499                refusal.detail
500            );
501            // A refusal nothing can change exhausts the item now rather than
502            // four sessions from now. See `DeliverRefusal`.
503            if refusal.permanent {
504                claims.record_permanent_failure(&item.repo, item.number, &detail, now);
505            } else {
506                claims.record_failure(&item.repo, item.number, &detail, now);
507            }
508            claims.release(&item.repo, item.number, run_id);
509            // The case a human most needs told, so it comments like every other
510            // stop rather than failing silently.
511            let _ = io.comment(&item, &io.redact(&detail)).await;
512            TickOutcome::Failed { detail }
513        }
514    }
515}
516
517/// The checkout argument a coder session takes for this target.
518pub fn coder_target(target: &HealTarget) -> Option<(Option<std::path::PathBuf>, Option<String>)> {
519    match target.checkout.as_ref()? {
520        Checkout::Local(p) => Some((Some(p.clone()), None)),
521        Checkout::Project(slug) => Some((None, Some(slug.clone()))),
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    use crate::coder::heal_intake::{ChecksState, RawPullRequest};
530    use crate::coder::heal_select::{CandidateKind, CLAIM_TTL_MS};
531    use crate::coder::provenance::ProvenanceTier;
532    use std::sync::Mutex;
533
534    #[derive(Default)]
535    struct Fake {
536        candidates: Vec<Candidate>,
537        prs: Vec<RawPullRequest>,
538        intent: Option<String>,
539        intent_err: Option<String>,
540        attempt: Option<Attempt>,
541        coder_err: Option<String>,
542        coder_configuration_error: bool,
543        pr_err: Option<String>,
544        ci_unavailable: bool,
545        calls: Mutex<Vec<String>>,
546        /// The fake's clock. Larger than `CLAIM_TTL_MS` so a test can express
547        /// "claimed before the window" without underflowing.
548        now: u64,
549        /// Which refusal `intent: None` stands for: a tier resolved too long
550        /// ago (retry works) rather than an untrusted author (it does not).
551        intent_stale: bool,
552    }
553
554    #[async_trait::async_trait]
555    impl TickIo for Fake {
556        async fn candidates(&self, _t: &HealTarget) -> Result<Vec<Candidate>, String> {
557            self.calls.lock().unwrap().push("candidates".into());
558            Ok(self.candidates.clone())
559        }
560        async fn open_prs(&self, _t: &HealTarget) -> Result<Vec<RawPullRequest>, String> {
561            Ok(self.prs.clone())
562        }
563        async fn intent_for(&self, _i: &Candidate) -> Result<Intent, String> {
564            self.calls.lock().unwrap().push("intent".into());
565            if let Some(e) = &self.intent_err {
566                return Err(e.clone());
567            }
568            Ok(match self.intent.clone() {
569                Some(text) => Intent::Seed(SessionSeed::from_trusted(text)),
570                // `None` is a gate refusal — `Gone` is the other, distinct
571                // case, and `intent_stale` picks which refusal.
572                None => Intent::Refused {
573                    tier: if self.intent_stale {
574                        ProvenanceTier::Maintainer
575                    } else {
576                        ProvenanceTier::Public
577                    },
578                    stale: self.intent_stale,
579                },
580            })
581        }
582
583        fn redact(&self, text: &str) -> String {
584            text.replace("sk-secret", "[redacted]")
585        }
586        async fn run_coder(
587            &self,
588            _t: &HealTarget,
589            _i: &Candidate,
590            _intent: &SessionSeed,
591        ) -> Result<Attempt, RunFailure> {
592            self.calls.lock().unwrap().push("coder".into());
593            if let Some(e) = &self.coder_err {
594                // The fake's coder failure names a session, so the tick's
595                // abandon path is exercised rather than skipped.
596                return Err(if self.coder_configuration_error {
597                    RunFailure::configuration_with_session("coder-fake", e.clone())
598                } else {
599                    RunFailure::with_session("coder-fake", e.clone())
600                });
601            }
602            Ok(self.attempt.clone().unwrap_or(Attempt {
603                contract_passed: true,
604                contract_detail: "green".into(),
605                panel_size: 3,
606                verdicts: vec![
607                    Verdict {
608                        model: "a".into(),
609                        pass: true,
610                        reason: "ok".into(),
611                    },
612                    Verdict {
613                        model: "b".into(),
614                        pass: true,
615                        reason: "ok".into(),
616                    },
617                ],
618                unreachable: vec![],
619                session_id: "coder-e2e-1".into(),
620            }))
621        }
622        async fn deliver(
623            &self,
624            _t: &HealTarget,
625            _i: &Candidate,
626            _s: &str,
627            _g: &GateOutcome,
628        ) -> Result<PrDeliveryOutcome, DeliverRefusal> {
629            self.calls.lock().unwrap().push("deliver".into());
630            if let Some(e) = &self.pr_err {
631                return Err(DeliverRefusal::retriable(e.clone()));
632            }
633            let mut out = fake_delivery();
634            if self.ci_unavailable {
635                out.ci.state = super::super::merge::CiState::Pending;
636                out.ci.checks.clear();
637                out.ci.observation_error = Some("HTTP 503".into());
638            }
639            Ok(out)
640        }
641        async fn abandon(&self, _s: &str) {
642            self.calls.lock().unwrap().push("abandon".into());
643        }
644        async fn comment(&self, _i: &Candidate, _t: &str) -> Result<(), String> {
645            self.calls.lock().unwrap().push("comment".into());
646            Ok(())
647        }
648        fn now_ms(&self) -> u64 {
649            if self.now == 0 {
650                10 * CLAIM_TTL_MS
651            } else {
652                self.now
653            }
654        }
655    }
656
657    fn target() -> HealTarget {
658        HealTarget {
659            repo: "acme/widgets".into(),
660            fix_repo: None,
661            checkout: Some(Checkout::Project("widgets".into())),
662            label: "self-heal".into(),
663            base: "main".into(),
664        }
665    }
666
667    fn item() -> Candidate {
668        Candidate {
669            repo: "acme/widgets".into(),
670            number: 5,
671            tier: Some(ProvenanceTier::Maintainer),
672            labelled: true,
673            created_ms: 0,
674            kind: CandidateKind::Issue,
675        }
676    }
677
678    /// Keep a typed handle AND a trait object over the same fake, so a test can
679    /// assert on recorded calls without casting a `dyn` pointer back.
680    fn io(f: Fake) -> (Arc<Fake>, Arc<dyn TickIo>) {
681        let typed = Arc::new(f);
682        let dynamic: Arc<dyn TickIo> = typed.clone();
683        (typed, dynamic)
684    }
685
686    fn calls_of(f: &Arc<Fake>) -> Vec<String> {
687        f.calls.lock().unwrap().clone()
688    }
689
690    fn fake_delivery() -> PrDeliveryOutcome {
691        PrDeliveryOutcome {
692            branch: "self-heal/5".into(),
693            commit: "head123".into(),
694            pushed: true,
695            pr_number: 1,
696            pr_url: "https://example/pr/1".into(),
697            pr_action: super::super::merge::PrAction::Opened,
698            draft: false,
699            ci: CiSummary {
700                observation_error: None,
701                head_sha: "head123".into(),
702                state: super::super::merge::CiState::Green,
703                checks: vec![
704                    super::super::merge::CiCheck {
705                        name: "lint".into(),
706                        state: super::super::merge::CiState::Green,
707                    },
708                    super::super::merge::CiCheck {
709                        name: "test".into(),
710                        state: super::super::merge::CiState::Green,
711                    },
712                ],
713            },
714        }
715    }
716
717    /// THE starvation regression, and it was opened by the tier fix itself.
718    ///
719    /// Before untiered candidates were allowed past selection, nothing reached
720    /// `intent_for` and this path was dead. Now every labelled issue reaches
721    /// it, and a refusal used to release the claim and record nothing — so the
722    /// oldest labelled item with an untrusted author (the normal case in a
723    /// public repo: a maintainer labels a contributor's bug report) was chosen
724    /// on every tick, refused on every tick, and halted the sweep there. Every
725    /// item behind it starved forever.
726    #[tokio::test]
727    async fn a_refused_author_is_recorded_so_the_queue_can_move_past_it() {
728        let mut older = item();
729        older.number = 1;
730        older.created_ms = 1;
731        let (_, f) = io(Fake {
732            candidates: vec![older],
733            // The fake's `None` intent is a gate refusal.
734            intent: None,
735            now: 1_000_000,
736            ..Default::default()
737        });
738        let mut claims = ClaimStore::new();
739        let out = tick(&f, &target(), &mut claims, "run-1", &NoClaimSink).await;
740
741        match out {
742            TickOutcome::Idle { skipped } => assert!(matches!(
743                skipped[0].reason,
744                super::super::heal_select::Skip::UntrustedAuthor { .. }
745            )),
746            other => panic!("expected idle, got {other:?}"),
747        }
748        assert_eq!(claims.held_by("acme/widgets", 1), None);
749        assert!(!claims.attempts().is_empty(), "no failure was recorded");
750
751        // THE assertion that matters, and the one the first version of this
752        // test was missing: it checked only that the WRITE happened. The
753        // attempt ledger was written by `tick` and read by nobody —
754        // `select` never received it and `Skip::RecentlyFailed` was
755        // constructed nowhere in production — so the very next tick chose the
756        // same item again and the sweep never advanced. Proving a backoff
757        // requires ticking twice.
758        let out2 = tick(&f, &target(), &mut claims, "run-2", &NoClaimSink).await;
759        match out2 {
760            TickOutcome::Idle { skipped } => assert!(
761                matches!(
762                    skipped[0].reason,
763                    super::super::heal_select::Skip::RecentlyFailed { .. }
764                ),
765                "the second tick re-selected the item: {:?}",
766                skipped[0].reason
767            ),
768            other => panic!("expected the item to be held back, got {other:?}"),
769        }
770    }
771
772    /// The backoff must also EXPIRE, or a transient failure parks an item for
773    /// good and the ledger becomes a denylist.
774    #[tokio::test]
775    async fn a_stale_refusal_becomes_eligible_again_once_its_backoff_passes() {
776        let mut claims = ClaimStore::new();
777        claims.record_failure("acme/widgets", 5, "transient", 0);
778        let (_, f) = io(Fake {
779            candidates: vec![item()],
780            intent: Some("fix it".into()),
781            // Past the first backoff step.
782            now: super::super::heal_claims::BACKOFF_BASE_MS + 1,
783            ..Default::default()
784        });
785        let out = tick(&f, &target(), &mut claims, "run-1", &NoClaimSink).await;
786        assert!(
787            !matches!(out, TickOutcome::Idle { .. }),
788            "an expired backoff must release the item, got {out:?}"
789        );
790    }
791
792    /// The other half: an item that VANISHED needs no backoff. It is gone from
793    /// the queue, so a later tick will not see it, and recording a failure
794    /// would leave a phantom entry against an issue nobody can look at.
795    #[tokio::test]
796    async fn an_item_that_vanished_is_not_recorded_as_a_failure() {
797        struct Vanished;
798        #[async_trait::async_trait]
799        impl TickIo for Vanished {
800            async fn candidates(&self, _t: &HealTarget) -> Result<Vec<Candidate>, String> {
801                Ok(vec![item()])
802            }
803            async fn open_prs(&self, _t: &HealTarget) -> Result<Vec<RawPullRequest>, String> {
804                Ok(vec![])
805            }
806            async fn intent_for(&self, _i: &Candidate) -> Result<Intent, String> {
807                Ok(Intent::Gone)
808            }
809            fn redact(&self, t: &str) -> String {
810                t.to_string()
811            }
812            async fn run_coder(
813                &self,
814                _t: &HealTarget,
815                _i: &Candidate,
816                _s: &SessionSeed,
817            ) -> Result<Attempt, RunFailure> {
818                panic!("a vanished item must not reach the coder")
819            }
820            async fn deliver(
821                &self,
822                _t: &HealTarget,
823                _i: &Candidate,
824                _s: &str,
825                _g: &GateOutcome,
826            ) -> Result<PrDeliveryOutcome, DeliverRefusal> {
827                panic!("a vanished item must not be delivered")
828            }
829            async fn abandon(&self, _s: &str) {}
830            async fn comment(&self, _i: &Candidate, _t: &str) -> Result<(), String> {
831                Ok(())
832            }
833            fn now_ms(&self) -> u64 {
834                1_000_000
835            }
836        }
837        let io: Arc<dyn TickIo> = Arc::new(Vanished);
838        let mut claims = ClaimStore::new();
839        let out = tick(&io, &target(), &mut claims, "run-1", &NoClaimSink).await;
840        match out {
841            TickOutcome::Idle { skipped } => {
842                assert_eq!(skipped[0].reason, super::super::heal_select::Skip::Gone)
843            }
844            other => panic!("expected idle, got {other:?}"),
845        }
846        assert!(
847            claims.attempts().is_empty(),
848            "a vanished item leaves no backoff against an issue nobody can see"
849        );
850    }
851
852    /// A stale tier is not an authorisation failure, and saying so would send
853    /// an operator to check permissions when the remedy is to retry.
854    #[tokio::test]
855    async fn a_stale_tier_reports_as_not_cleared_not_as_untrusted() {
856        struct Stale;
857        #[async_trait::async_trait]
858        impl TickIo for Stale {
859            async fn candidates(&self, _t: &HealTarget) -> Result<Vec<Candidate>, String> {
860                Ok(vec![item()])
861            }
862            async fn open_prs(&self, _t: &HealTarget) -> Result<Vec<RawPullRequest>, String> {
863                Ok(vec![])
864            }
865            async fn intent_for(&self, _i: &Candidate) -> Result<Intent, String> {
866                Ok(Intent::Refused {
867                    tier: ProvenanceTier::Maintainer,
868                    stale: true,
869                })
870            }
871            fn redact(&self, t: &str) -> String {
872                t.to_string()
873            }
874            async fn run_coder(
875                &self,
876                _t: &HealTarget,
877                _i: &Candidate,
878                _s: &SessionSeed,
879            ) -> Result<Attempt, RunFailure> {
880                panic!("unreached")
881            }
882            async fn deliver(
883                &self,
884                _t: &HealTarget,
885                _i: &Candidate,
886                _s: &str,
887                _g: &GateOutcome,
888            ) -> Result<PrDeliveryOutcome, DeliverRefusal> {
889                panic!("unreached")
890            }
891            async fn abandon(&self, _s: &str) {}
892            async fn comment(&self, _i: &Candidate, _t: &str) -> Result<(), String> {
893                Ok(())
894            }
895            fn now_ms(&self) -> u64 {
896                1_000_000
897            }
898        }
899        let io: Arc<dyn TickIo> = Arc::new(Stale);
900        let mut claims = ClaimStore::new();
901        match tick(&io, &target(), &mut claims, "run-1", &NoClaimSink).await {
902            TickOutcome::Idle { skipped } => assert_eq!(
903                skipped[0].reason,
904                super::super::heal_select::Skip::IntentNotCleared
905            ),
906            other => panic!("expected idle, got {other:?}"),
907        }
908        // Still recorded: a stale tier that keeps being stale would otherwise
909        // halt the sweep on this item exactly as an untrusted author did.
910        assert!(!claims.attempts().is_empty());
911    }
912
913    /// A coder failure that left a live session must close it out.
914    ///
915    /// `run` can fail AFTER the session reached `NeedsApproval` — no worktree,
916    /// an unreadable diff, a green contract over an unchanged worktree — and a
917    /// plain `String` error gave the caller no way to name it. Each one leaked
918    /// a git worktree registered in the operator's own repository, with the
919    /// task handle already taken so `coder.cancel` could not reach it, and the
920    /// reaper that would have swept them was deleted on the strength of the
921    /// invariant these paths break.
922    #[tokio::test]
923    async fn a_failed_coder_run_still_closes_its_session() {
924        let (typed, f) = io(Fake {
925            candidates: vec![item()],
926            intent: Some("fix it".into()),
927            coder_err: Some("the worktree is unchanged".into()),
928            ..Default::default()
929        });
930        let mut claims = ClaimStore::new();
931        let out = tick(&f, &target(), &mut claims, "run-1", &NoClaimSink).await;
932        assert!(matches!(out, TickOutcome::Failed { .. }));
933        assert!(
934            calls_of(&typed).contains(&"abandon".to_string()),
935            "the session was left non-terminal, holding a worktree: {:?}",
936            calls_of(&typed)
937        );
938    }
939
940    /// Exhausting reviewer exclusions is a daemon configuration failure, not
941    /// evidence that the selected backlog item is bad. The tick still closes
942    /// the session and releases its claim, but leaves neither item backoff nor
943    /// a public failure comment.
944    #[tokio::test]
945    async fn no_independent_coder_does_not_penalize_or_comment_on_the_item() {
946        let (typed, f) = io(Fake {
947            candidates: vec![item()],
948            intent: Some("fix it".into()),
949            coder_err: Some("no independent coder model is available; configure heal.toml".into()),
950            coder_configuration_error: true,
951            ..Default::default()
952        });
953        let mut claims = ClaimStore::new();
954        let out = tick(&f, &target(), &mut claims, "run-1", &NoClaimSink).await;
955
956        assert!(matches!(out, TickOutcome::Failed { .. }));
957        assert!(claims.attempts().is_empty(), "item gained failure backoff");
958        assert_eq!(claims.held_by("acme/widgets", 5), None);
959        assert_eq!(
960            calls_of(&typed),
961            vec!["candidates", "intent", "coder", "abandon"],
962            "configuration failure must not post on the backlog item"
963        );
964    }
965
966    #[tokio::test]
967    async fn an_empty_queue_is_idle_not_failure() {
968        let (_, f) = io(Fake {
969            intent: Some("fix it".into()),
970            ..Default::default()
971        });
972        let mut claims = ClaimStore::new();
973        let out = tick(&f, &target(), &mut claims, "run-1", &NoClaimSink).await;
974        assert!(matches!(out, TickOutcome::Idle { .. }));
975    }
976
977    #[tokio::test]
978    async fn a_watch_only_target_spends_no_api_call() {
979        let (typed, arc) = io(Fake {
980            candidates: vec![item()],
981            intent: Some("fix it".into()),
982            ..Default::default()
983        });
984        let mut t = target();
985        t.checkout = None;
986        let out = tick(&arc, &t, &mut claims_new(), "run-1", &NoClaimSink).await;
987        assert!(matches!(out, TickOutcome::Idle { .. }));
988        // Reading a queue we can do nothing about is pure cost.
989        assert!(calls_of(&typed).is_empty());
990    }
991
992    fn claims_new() -> ClaimStore {
993        ClaimStore::new()
994    }
995
996    #[tokio::test]
997    async fn an_invalid_repo_spec_fails_rather_than_idling() {
998        let (_, f) = io(Fake::default());
999        let mut t = target();
1000        t.repo = "not-a-spec".into();
1001        let out = tick(&f, &t, &mut claims_new(), "run-1", &NoClaimSink).await;
1002        assert!(matches!(out, TickOutcome::Failed { .. }));
1003    }
1004
1005    #[tokio::test]
1006    async fn unavailable_ci_keeps_delivery_claim_without_failure_backoff() {
1007        let (typed, arc) = io(Fake {
1008            candidates: vec![item()],
1009            intent: Some("fix parser".into()),
1010            ci_unavailable: true,
1011            ..Default::default()
1012        });
1013        let mut claims = ClaimStore::new();
1014        let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
1015        assert!(
1016            matches!(out, TickOutcome::Opened { ref delivery, .. } if delivery.contains("CI unavailable"))
1017        );
1018        assert_eq!(claims.held_by("acme/widgets", 5), Some("run-1"));
1019        assert!(claims.attempts().is_empty());
1020        assert!(!calls_of(&typed).contains(&"abandon".to_string()));
1021        let again = tick(&arc, &target(), &mut claims, "run-2", &NoClaimSink).await;
1022        assert!(matches!(again, TickOutcome::Idle { .. }));
1023        assert_eq!(
1024            calls_of(&typed)
1025                .iter()
1026                .filter(|c| c.as_str() == "deliver")
1027                .count(),
1028            1
1029        );
1030    }
1031
1032    #[tokio::test]
1033    async fn a_happy_tick_claims_then_works_then_opens() {
1034        let (typed, arc) = io(Fake {
1035            candidates: vec![item()],
1036            intent: Some("fix the parser".into()),
1037            ..Default::default()
1038        });
1039        let mut claims = ClaimStore::new();
1040        let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
1041        match out {
1042            TickOutcome::Opened {
1043                number,
1044                pr_url,
1045                ci,
1046                delivery,
1047                ..
1048            } => {
1049                assert_eq!(number, 5);
1050                assert!(pr_url.contains("pr/1"));
1051                assert_eq!(ci.head_sha, "head123");
1052                assert_eq!(ci.state, super::super::merge::CiState::Green);
1053                assert_eq!(
1054                    delivery,
1055                    "delivered with green checks and ready for review at head123"
1056                );
1057            }
1058            other => panic!("expected Opened, got {other:?}"),
1059        }
1060        // The claim is HELD after success: the item is not free until a human
1061        // closes it, and a later tick must not immediately redo the work.
1062        assert_eq!(claims.held_by("acme/widgets", 5), Some("run-1"));
1063
1064        let calls = calls_of(&typed);
1065        let claim_before_work = calls.iter().position(|c| c == "coder").unwrap();
1066        assert!(
1067            calls[..claim_before_work].contains(&"intent".to_string()),
1068            "intent is cleared before the coder runs: {calls:?}"
1069        );
1070    }
1071
1072    #[tokio::test]
1073    async fn an_uncleared_intent_releases_the_claim() {
1074        // `None` means the item could not be trust-cleared. It must never fall
1075        // back to the raw body, and it must not leave the item claimed.
1076        let (typed, arc) = io(Fake {
1077            candidates: vec![item()],
1078            intent: None,
1079            ..Default::default()
1080        });
1081        let mut claims = ClaimStore::new();
1082        let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
1083        assert!(matches!(out, TickOutcome::Idle { .. }));
1084        assert_eq!(claims.held_by("acme/widgets", 5), None, "claim released");
1085
1086        assert!(
1087            !calls_of(&typed).contains(&"coder".to_string()),
1088            "the coder must not run on uncleared intent"
1089        );
1090    }
1091
1092    #[tokio::test]
1093    async fn a_rejected_gate_comments_and_releases() {
1094        let (typed, arc) = io(Fake {
1095            candidates: vec![item()],
1096            intent: Some("fix it".into()),
1097            attempt: Some(Attempt {
1098                contract_passed: true,
1099                contract_detail: "green".into(),
1100                panel_size: 3,
1101                verdicts: vec![
1102                    Verdict {
1103                        model: "a".into(),
1104                        pass: true,
1105                        reason: "ok".into(),
1106                    },
1107                    Verdict {
1108                        model: "b".into(),
1109                        pass: false,
1110                        reason: "changes unrelated behaviour".into(),
1111                    },
1112                    Verdict {
1113                        model: "c".into(),
1114                        pass: false,
1115                        reason: "scope".into(),
1116                    },
1117                ],
1118                unreachable: vec![],
1119                session_id: "coder-e2e-1".into(),
1120            }),
1121            ..Default::default()
1122        });
1123        let mut claims = ClaimStore::new();
1124        let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
1125        assert!(matches!(out, TickOutcome::Rejected { .. }));
1126        assert_eq!(claims.held_by("acme/widgets", 5), None);
1127
1128        let calls = calls_of(&typed);
1129        assert!(
1130            calls.contains(&"comment".to_string()),
1131            "must explain itself"
1132        );
1133        assert!(
1134            !calls.contains(&"deliver".to_string()),
1135            "a rejected change must not be opened"
1136        );
1137    }
1138
1139    #[tokio::test]
1140    async fn a_failed_coder_releases_the_claim() {
1141        let (_, arc) = io(Fake {
1142            candidates: vec![item()],
1143            intent: Some("fix it".into()),
1144            coder_err: Some("worktree gone".into()),
1145            ..Default::default()
1146        });
1147        let mut claims = ClaimStore::new();
1148        let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
1149        assert!(matches!(out, TickOutcome::Failed { .. }));
1150        assert_eq!(claims.held_by("acme/widgets", 5), None);
1151    }
1152
1153    #[tokio::test]
1154    async fn a_failed_pr_open_releases_so_a_later_tick_can_retry() {
1155        let (_, arc) = io(Fake {
1156            candidates: vec![item()],
1157            intent: Some("fix it".into()),
1158            pr_err: Some("gh auth expired".into()),
1159            ..Default::default()
1160        });
1161        let mut claims = ClaimStore::new();
1162        let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
1163        assert!(matches!(out, TickOutcome::Failed { .. }));
1164        assert_eq!(
1165            claims.held_by("acme/widgets", 5),
1166            None,
1167            "otherwise the item sits claimed and invisible"
1168        );
1169    }
1170
1171    #[tokio::test]
1172    async fn an_item_claimed_by_another_run_is_left_alone() {
1173        let (typed, arc) = io(Fake {
1174            candidates: vec![item()],
1175            intent: Some("fix it".into()),
1176            ..Default::default()
1177        });
1178        let mut claims = ClaimStore::new();
1179        claims
1180            .claim("acme/widgets", 5, "other-run", 10 * CLAIM_TTL_MS)
1181            .unwrap();
1182        let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
1183        assert!(matches!(out, TickOutcome::Idle { .. }));
1184        assert_eq!(claims.held_by("acme/widgets", 5), Some("other-run"));
1185
1186        assert!(!calls_of(&typed).contains(&"coder".to_string()));
1187    }
1188
1189    #[tokio::test]
1190    async fn an_expired_claim_lets_a_later_tick_pick_it_up() {
1191        let (_, arc) = io(Fake {
1192            candidates: vec![item()],
1193            intent: Some("fix it".into()),
1194            ..Default::default()
1195        });
1196        let mut claims = ClaimStore::new();
1197        // The fake's clock is 10x the TTL, so a claim at time 0 is long dead.
1198        claims.claim("acme/widgets", 5, "dead-run", 0).unwrap();
1199        let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
1200        assert!(matches!(out, TickOutcome::Opened { .. }));
1201    }
1202
1203    #[tokio::test]
1204    async fn an_issue_already_covered_is_not_worked_twice() {
1205        let (_, arc) = io(Fake {
1206            candidates: vec![item()],
1207            prs: vec![RawPullRequest::new(
1208                "acme/widgets",
1209                9,
1210                "someone",
1211                "fix",
1212                "closes #5",
1213                vec![],
1214                ChecksState::Passing,
1215                "",
1216                false,
1217            )],
1218            intent: Some("fix it".into()),
1219            ..Default::default()
1220        });
1221        let out = tick(&arc, &target(), &mut claims_new(), "run-1", &NoClaimSink).await;
1222        assert!(matches!(out, TickOutcome::Idle { .. }));
1223    }
1224
1225    #[test]
1226    fn a_project_target_and_a_local_target_map_to_different_coder_arguments() {
1227        let (repo, project) = coder_target(&target()).unwrap();
1228        assert!(repo.is_none() && project.as_deref() == Some("widgets"));
1229
1230        let mut t = target();
1231        t.checkout = Some(Checkout::Local("/tmp/x".into()));
1232        let (repo, project) = coder_target(&t).unwrap();
1233        assert!(repo.is_some() && project.is_none());
1234    }
1235
1236    #[tokio::test]
1237    async fn a_rejected_item_is_not_retried_on_the_very_next_tick() {
1238        // THE bug both reviews ranked first. Oldest-first selection plus
1239        // release-on-failure plus no memory = a metronome pointed at one issue,
1240        // burning a coder session and a model panel every tick while everything
1241        // behind it starves.
1242        let (_, arc) = io(Fake {
1243            candidates: vec![item()],
1244            intent: Some("fix it".into()),
1245            attempt: Some(Attempt {
1246                contract_passed: true,
1247                contract_detail: "green".into(),
1248                panel_size: 3,
1249                verdicts: vec![
1250                    Verdict {
1251                        model: "a".into(),
1252                        pass: false,
1253                        reason: "no".into(),
1254                    },
1255                    Verdict {
1256                        model: "b".into(),
1257                        pass: false,
1258                        reason: "no".into(),
1259                    },
1260                ],
1261                unreachable: vec![],
1262                session_id: "coder-b".into(),
1263            }),
1264            ..Default::default()
1265        });
1266        let mut claims = ClaimStore::new();
1267        let first = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
1268        assert!(matches!(first, TickOutcome::Rejected { .. }));
1269        assert_eq!(claims.attempts().len(), 1, "the failure is remembered");
1270
1271        // The claim was released, so only the backoff can stop a re-pick.
1272        let a = &claims.attempts()[&crate::coder::heal_select::claim_key("acme/widgets", 5)];
1273        assert!(!a.ready(arc.now_ms()), "not eligible again immediately");
1274    }
1275
1276    #[tokio::test]
1277    async fn a_caller_cannot_mint_approval_it_did_not_earn() {
1278        // `run_coder` returns evidence; only `decide` builds a verdict. A
1279        // failing contract cannot be reported as approved no matter what the
1280        // panel says.
1281        let (_, arc) = io(Fake {
1282            candidates: vec![item()],
1283            intent: Some("fix it".into()),
1284            attempt: Some(Attempt {
1285                contract_passed: false,
1286                contract_detail: "cargo test failed".into(),
1287                panel_size: 3,
1288                verdicts: vec![
1289                    Verdict {
1290                        model: "a".into(),
1291                        pass: true,
1292                        reason: "lgtm".into(),
1293                    },
1294                    Verdict {
1295                        model: "b".into(),
1296                        pass: true,
1297                        reason: "lgtm".into(),
1298                    },
1299                    Verdict {
1300                        model: "c".into(),
1301                        pass: true,
1302                        reason: "lgtm".into(),
1303                    },
1304                ],
1305                unreachable: vec![],
1306                session_id: "coder-b".into(),
1307            }),
1308            ..Default::default()
1309        });
1310        let out = tick(
1311            &arc,
1312            &target(),
1313            &mut ClaimStore::new(),
1314            "run-1",
1315            &NoClaimSink,
1316        )
1317        .await;
1318        match out {
1319            TickOutcome::Rejected { gate, .. } => {
1320                assert!(gate.contains("contract"), "{gate}");
1321            }
1322            other => panic!("a red contract must not open a PR: {other:?}"),
1323        }
1324    }
1325
1326    #[tokio::test]
1327    async fn a_success_clears_the_failure_history() {
1328        let (_, arc) = io(Fake {
1329            candidates: vec![item()],
1330            intent: Some("fix it".into()),
1331            ..Default::default()
1332        });
1333        let mut claims = ClaimStore::new();
1334        claims.record_failure("acme/widgets", 5, "earlier", 0);
1335        let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
1336        assert!(matches!(out, TickOutcome::Opened { .. }));
1337        assert!(claims.attempts().is_empty(), "a later failure starts clean");
1338    }
1339
1340    #[tokio::test]
1341    async fn every_stop_says_why_on_the_item() {
1342        // A gate that passed but whose PR could not be opened is the case a
1343        // human most needs told, and it used to return silently.
1344        let (typed, arc) = io(Fake {
1345            candidates: vec![item()],
1346            intent: Some("fix it".into()),
1347            pr_err: Some("gh auth expired".into()),
1348            ..Default::default()
1349        });
1350        let out = tick(
1351            &arc,
1352            &target(),
1353            &mut ClaimStore::new(),
1354            "run-1",
1355            &NoClaimSink,
1356        )
1357        .await;
1358        assert!(matches!(out, TickOutcome::Failed { .. }));
1359        assert!(
1360            calls_of(&typed).contains(&"comment".to_string()),
1361            "a silent failure teaches people to ignore the loop"
1362        );
1363    }
1364
1365    #[tokio::test]
1366    async fn a_watch_only_target_reports_why_it_did_nothing() {
1367        let (_, arc) = io(Fake::default());
1368        let mut t = target();
1369        t.checkout = None;
1370        match tick(&arc, &t, &mut ClaimStore::new(), "run-1", &NoClaimSink).await {
1371            TickOutcome::Idle { skipped } => {
1372                assert_eq!(skipped.len(), 1);
1373                assert_eq!(
1374                    skipped[0].reason,
1375                    crate::coder::heal_select::Skip::WatchOnly
1376                );
1377            }
1378            other => panic!("expected an explained idle, got {other:?}"),
1379        }
1380    }
1381
1382    #[tokio::test]
1383    async fn an_uncleared_intent_reports_clearance_not_authorisation() {
1384        // Reporting "untrusted author: maintainer" sends an operator to check
1385        // permissions when the remedy is to retry.
1386        let (_, arc) = io(Fake {
1387            candidates: vec![item()],
1388            intent: None,
1389            intent_stale: true,
1390            ..Default::default()
1391        });
1392        match tick(
1393            &arc,
1394            &target(),
1395            &mut ClaimStore::new(),
1396            "run-1",
1397            &NoClaimSink,
1398        )
1399        .await
1400        {
1401            TickOutcome::Idle { skipped } => {
1402                assert_eq!(
1403                    skipped[0].reason,
1404                    crate::coder::heal_select::Skip::IntentNotCleared
1405                );
1406            }
1407            other => panic!("expected an explained idle, got {other:?}"),
1408        }
1409    }
1410}