1use 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#[derive(Debug)]
50pub enum Intent {
51 Seed(SessionSeed),
53 Gone,
56 Refused { tier: ProvenanceTier, stale: bool },
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct RunFailure {
77 pub detail: String,
78 pub session_id: Option<String>,
80 pub configuration: bool,
83}
84
85impl RunFailure {
86 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 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 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Attempt {
145 pub contract_passed: bool,
147 pub contract_detail: String,
149 pub panel_size: usize,
152 pub verdicts: Vec<Verdict>,
153 pub unreachable: Vec<Unreachable>,
154 pub session_id: String,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
169#[serde(tag = "outcome", rename_all = "snake_case")]
170pub enum TickOutcome {
171 Idle { skipped: Vec<SkippedItem> },
174 Opened {
176 repo: String,
177 number: u64,
178 pr_url: String,
179 gate: String,
180 ci: CiSummary,
182 delivery: String,
184 },
185 Rejected {
188 repo: String,
189 number: u64,
190 gate: String,
191 },
192 Failed { detail: String },
195}
196
197#[async_trait::async_trait]
204pub trait TickIo: Send + Sync {
205 async fn candidates(&self, target: &HealTarget) -> Result<Vec<Candidate>, String>;
207 async fn open_prs(
209 &self,
210 target: &HealTarget,
211 ) -> Result<Vec<super::heal_intake::RawPullRequest>, String>;
212 async fn intent_for(&self, item: &Candidate) -> Result<Intent, String>;
219 async fn run_coder(
228 &self,
229 target: &HealTarget,
230 item: &Candidate,
231 intent: &SessionSeed,
232 ) -> Result<Attempt, RunFailure>;
233 fn redact(&self, text: &str) -> String;
239 async fn deliver(
246 &self,
247 target: &HealTarget,
248 item: &Candidate,
249 session_id: &str,
250 gate: &GateOutcome,
251 ) -> Result<PrDeliveryOutcome, DeliverRefusal>;
252
253 async fn abandon(&self, session_id: &str);
261 async fn comment(&self, item: &Candidate, text: &str) -> Result<(), String>;
263 fn now_ms(&self) -> u64;
264}
265
266pub trait ClaimSink: Send + Sync {
276 fn persist(&self, claims: &ClaimStore, now_ms: u64);
277}
278
279pub 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 if !target.can_write() {
296 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 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 let intent = match io.intent_for(&item).await {
356 Ok(Intent::Seed(i)) => i,
357 Ok(Intent::Gone) => {
358 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 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 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 if let Some(id) = &failure.session_id {
424 io.abandon(id).await;
425 }
426 let detail = failure.detail;
427 if failure.configuration {
428 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 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 io.abandon(&attempt.session_id).await;
463 claims.record_failure(&item.repo, item.number, &summary, now);
466 claims.release(&item.repo, item.number, run_id);
467 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 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 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 let _ = io.comment(&item, &io.redact(&detail)).await;
512 TickOutcome::Failed { detail }
513 }
514 }
515}
516
517pub 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 now: u64,
549 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 => 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 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 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 #[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 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 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 #[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 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 #[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 #[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 assert!(!claims.attempts().is_empty());
911 }
912
913 #[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 #[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 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 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 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 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 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 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 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 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 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}