use std::sync::Arc;
use super::heal_claims::ClaimStore;
use super::heal_gate::{decide, GateOutcome, Unreachable, Verdict};
use super::heal_intake::{Checkout, HealTarget};
use super::heal_select::{select, Candidate, Selection, SkippedItem};
use super::merge::{CiSummary, PrDeliveryOutcome};
use super::provenance::{ProvenanceTier, SessionSeed};
#[derive(Debug)]
pub enum Intent {
Seed(SessionSeed),
Gone,
Refused { tier: ProvenanceTier, stale: bool },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunFailure {
pub detail: String,
pub session_id: Option<String>,
pub configuration: bool,
}
impl RunFailure {
pub fn early(detail: impl Into<String>) -> Self {
Self {
detail: detail.into(),
session_id: None,
configuration: false,
}
}
pub fn with_session(session_id: &str, detail: impl Into<String>) -> Self {
Self {
detail: detail.into(),
session_id: Some(session_id.to_string()),
configuration: false,
}
}
pub fn configuration_with_session(session_id: &str, detail: impl Into<String>) -> Self {
Self {
detail: detail.into(),
session_id: Some(session_id.to_string()),
configuration: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeliverRefusal {
pub detail: String,
pub permanent: bool,
}
impl DeliverRefusal {
pub fn retriable(detail: impl Into<String>) -> Self {
Self {
detail: detail.into(),
permanent: false,
}
}
pub fn permanent(detail: impl Into<String>) -> Self {
Self {
detail: detail.into(),
permanent: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attempt {
pub contract_passed: bool,
pub contract_detail: String,
pub panel_size: usize,
pub verdicts: Vec<Verdict>,
pub unreachable: Vec<Unreachable>,
pub session_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum TickOutcome {
Idle { skipped: Vec<SkippedItem> },
Opened {
repo: String,
number: u64,
pr_url: String,
gate: String,
ci: CiSummary,
delivery: String,
},
Rejected {
repo: String,
number: u64,
gate: String,
},
Failed { detail: String },
}
#[async_trait::async_trait]
pub trait TickIo: Send + Sync {
async fn candidates(&self, target: &HealTarget) -> Result<Vec<Candidate>, String>;
async fn open_prs(
&self,
target: &HealTarget,
) -> Result<Vec<super::heal_intake::RawPullRequest>, String>;
async fn intent_for(&self, item: &Candidate) -> Result<Intent, String>;
async fn run_coder(
&self,
target: &HealTarget,
item: &Candidate,
intent: &SessionSeed,
) -> Result<Attempt, RunFailure>;
fn redact(&self, text: &str) -> String;
async fn deliver(
&self,
target: &HealTarget,
item: &Candidate,
session_id: &str,
gate: &GateOutcome,
) -> Result<PrDeliveryOutcome, DeliverRefusal>;
async fn abandon(&self, session_id: &str);
async fn comment(&self, item: &Candidate, text: &str) -> Result<(), String>;
fn now_ms(&self) -> u64;
}
pub trait ClaimSink: Send + Sync {
fn persist(&self, claims: &ClaimStore, now_ms: u64);
}
pub struct NoClaimSink;
impl ClaimSink for NoClaimSink {
fn persist(&self, _claims: &ClaimStore, _now_ms: u64) {}
}
pub async fn tick(
io: &Arc<dyn TickIo>,
target: &HealTarget,
claims: &mut ClaimStore,
run_id: &str,
sink: &dyn ClaimSink,
) -> TickOutcome {
if !target.can_write() {
return TickOutcome::Idle {
skipped: vec![SkippedItem {
repo: target.repo.clone(),
number: 0,
reason: super::heal_select::Skip::WatchOnly,
}],
};
}
if !super::heal_intake::is_valid_repo_spec(&target.repo) {
return TickOutcome::Failed {
detail: format!("`{}` is not a valid owner/name spec", target.repo),
};
}
let now = io.now_ms();
let candidates = match io.candidates(target).await {
Ok(c) => c,
Err(e) => return TickOutcome::Failed { detail: e },
};
let prs = match io.open_prs(target).await {
Ok(p) => p,
Err(e) => return TickOutcome::Failed { detail: e },
};
let Selection { chosen, skipped } = select(
target,
&candidates,
&prs,
claims.as_map(),
claims.attempts(),
now,
);
let Some(item) = chosen else {
return TickOutcome::Idle { skipped };
};
if let Err(refused) = claims.claim(&item.repo, item.number, run_id, now) {
return TickOutcome::Idle {
skipped: vec![SkippedItem {
repo: item.repo.clone(),
number: item.number,
reason: super::heal_select::Skip::Claimed {
run_id: refused.held_by,
},
}],
};
}
sink.persist(claims, now);
let intent = match io.intent_for(&item).await {
Ok(Intent::Seed(i)) => i,
Ok(Intent::Gone) => {
claims.release(&item.repo, item.number, run_id);
return TickOutcome::Idle {
skipped: vec![SkippedItem {
repo: item.repo.clone(),
number: item.number,
reason: super::heal_select::Skip::Gone,
}],
};
}
Ok(Intent::Refused { tier, stale }) => {
if stale {
claims.record_failure(
&item.repo,
item.number,
"provenance was resolved too long ago to rely on",
now,
);
} else {
claims.record_permanent_failure(
&item.repo,
item.number,
&format!("author is not cleared to seed a session (tier: {tier:?})"),
now,
);
}
claims.release(&item.repo, item.number, run_id);
return TickOutcome::Idle {
skipped: vec![SkippedItem {
repo: item.repo.clone(),
number: item.number,
reason: if stale {
super::heal_select::Skip::IntentNotCleared
} else {
super::heal_select::Skip::UntrustedAuthor { tier }
},
}],
};
}
Err(e) => {
claims.release(&item.repo, item.number, run_id);
return TickOutcome::Failed { detail: e };
}
};
let attempt = match io.run_coder(target, &item, &intent).await {
Ok(v) => v,
Err(failure) => {
if let Some(id) = &failure.session_id {
io.abandon(id).await;
}
let detail = failure.detail;
if failure.configuration {
claims.release(&item.repo, item.number, run_id);
return TickOutcome::Failed { detail };
}
claims.record_failure(&item.repo, item.number, &detail, now);
claims.release(&item.repo, item.number, run_id);
let _ = io
.comment(
&item,
&io.redact(&format!("self-heal could not run: {detail}")),
)
.await;
return TickOutcome::Failed { detail };
}
};
let gate = decide(
attempt.contract_passed,
&attempt.contract_detail,
attempt.panel_size,
&attempt.verdicts,
&attempt.unreachable,
);
if !gate.approved() {
let summary = gate.summary();
io.abandon(&attempt.session_id).await;
claims.record_failure(&item.repo, item.number, &summary, now);
claims.release(&item.repo, item.number, run_id);
let _ = io
.comment(&item, &io.redact(&format!("self-heal stopped: {summary}")))
.await;
return TickOutcome::Rejected {
repo: item.repo.clone(),
number: item.number,
gate: summary,
};
}
match io.deliver(target, &item, &attempt.session_id, &gate).await {
Ok(delivered) => {
claims.clear_failures(&item.repo, item.number);
let delivery = delivered.delivery_report();
TickOutcome::Opened {
repo: item.repo.clone(),
number: item.number,
pr_url: delivered.pr_url,
gate: gate.summary(),
ci: delivered.ci,
delivery,
}
}
Err(refusal) => {
io.abandon(&attempt.session_id).await;
let detail = format!(
"gate passed but the pull request could not be opened: {}",
refusal.detail
);
if refusal.permanent {
claims.record_permanent_failure(&item.repo, item.number, &detail, now);
} else {
claims.record_failure(&item.repo, item.number, &detail, now);
}
claims.release(&item.repo, item.number, run_id);
let _ = io.comment(&item, &io.redact(&detail)).await;
TickOutcome::Failed { detail }
}
}
}
pub fn coder_target(target: &HealTarget) -> Option<(Option<std::path::PathBuf>, Option<String>)> {
match target.checkout.as_ref()? {
Checkout::Local(p) => Some((Some(p.clone()), None)),
Checkout::Project(slug) => Some((None, Some(slug.clone()))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coder::heal_intake::{ChecksState, RawPullRequest};
use crate::coder::heal_select::{CandidateKind, CLAIM_TTL_MS};
use crate::coder::provenance::ProvenanceTier;
use std::sync::Mutex;
#[derive(Default)]
struct Fake {
candidates: Vec<Candidate>,
prs: Vec<RawPullRequest>,
intent: Option<String>,
intent_err: Option<String>,
attempt: Option<Attempt>,
coder_err: Option<String>,
coder_configuration_error: bool,
pr_err: Option<String>,
ci_unavailable: bool,
calls: Mutex<Vec<String>>,
now: u64,
intent_stale: bool,
}
#[async_trait::async_trait]
impl TickIo for Fake {
async fn candidates(&self, _t: &HealTarget) -> Result<Vec<Candidate>, String> {
self.calls.lock().unwrap().push("candidates".into());
Ok(self.candidates.clone())
}
async fn open_prs(&self, _t: &HealTarget) -> Result<Vec<RawPullRequest>, String> {
Ok(self.prs.clone())
}
async fn intent_for(&self, _i: &Candidate) -> Result<Intent, String> {
self.calls.lock().unwrap().push("intent".into());
if let Some(e) = &self.intent_err {
return Err(e.clone());
}
Ok(match self.intent.clone() {
Some(text) => Intent::Seed(SessionSeed::from_trusted(text)),
None => Intent::Refused {
tier: if self.intent_stale {
ProvenanceTier::Maintainer
} else {
ProvenanceTier::Public
},
stale: self.intent_stale,
},
})
}
fn redact(&self, text: &str) -> String {
text.replace("sk-secret", "[redacted]")
}
async fn run_coder(
&self,
_t: &HealTarget,
_i: &Candidate,
_intent: &SessionSeed,
) -> Result<Attempt, RunFailure> {
self.calls.lock().unwrap().push("coder".into());
if let Some(e) = &self.coder_err {
return Err(if self.coder_configuration_error {
RunFailure::configuration_with_session("coder-fake", e.clone())
} else {
RunFailure::with_session("coder-fake", e.clone())
});
}
Ok(self.attempt.clone().unwrap_or(Attempt {
contract_passed: true,
contract_detail: "green".into(),
panel_size: 3,
verdicts: vec![
Verdict {
model: "a".into(),
pass: true,
reason: "ok".into(),
},
Verdict {
model: "b".into(),
pass: true,
reason: "ok".into(),
},
],
unreachable: vec![],
session_id: "coder-e2e-1".into(),
}))
}
async fn deliver(
&self,
_t: &HealTarget,
_i: &Candidate,
_s: &str,
_g: &GateOutcome,
) -> Result<PrDeliveryOutcome, DeliverRefusal> {
self.calls.lock().unwrap().push("deliver".into());
if let Some(e) = &self.pr_err {
return Err(DeliverRefusal::retriable(e.clone()));
}
let mut out = fake_delivery();
if self.ci_unavailable {
out.ci.state = super::super::merge::CiState::Pending;
out.ci.checks.clear();
out.ci.observation_error = Some("HTTP 503".into());
}
Ok(out)
}
async fn abandon(&self, _s: &str) {
self.calls.lock().unwrap().push("abandon".into());
}
async fn comment(&self, _i: &Candidate, _t: &str) -> Result<(), String> {
self.calls.lock().unwrap().push("comment".into());
Ok(())
}
fn now_ms(&self) -> u64 {
if self.now == 0 {
10 * CLAIM_TTL_MS
} else {
self.now
}
}
}
fn target() -> HealTarget {
HealTarget {
repo: "acme/widgets".into(),
fix_repo: None,
checkout: Some(Checkout::Project("widgets".into())),
label: "self-heal".into(),
base: "main".into(),
}
}
fn item() -> Candidate {
Candidate {
repo: "acme/widgets".into(),
number: 5,
tier: Some(ProvenanceTier::Maintainer),
labelled: true,
created_ms: 0,
kind: CandidateKind::Issue,
}
}
fn io(f: Fake) -> (Arc<Fake>, Arc<dyn TickIo>) {
let typed = Arc::new(f);
let dynamic: Arc<dyn TickIo> = typed.clone();
(typed, dynamic)
}
fn calls_of(f: &Arc<Fake>) -> Vec<String> {
f.calls.lock().unwrap().clone()
}
fn fake_delivery() -> PrDeliveryOutcome {
PrDeliveryOutcome {
branch: "self-heal/5".into(),
commit: "head123".into(),
pushed: true,
pr_number: 1,
pr_url: "https://example/pr/1".into(),
pr_action: super::super::merge::PrAction::Opened,
draft: false,
ci: CiSummary {
observation_error: None,
head_sha: "head123".into(),
state: super::super::merge::CiState::Green,
checks: vec![
super::super::merge::CiCheck {
name: "lint".into(),
state: super::super::merge::CiState::Green,
},
super::super::merge::CiCheck {
name: "test".into(),
state: super::super::merge::CiState::Green,
},
],
},
}
}
#[tokio::test]
async fn a_refused_author_is_recorded_so_the_queue_can_move_past_it() {
let mut older = item();
older.number = 1;
older.created_ms = 1;
let (_, f) = io(Fake {
candidates: vec![older],
intent: None,
now: 1_000_000,
..Default::default()
});
let mut claims = ClaimStore::new();
let out = tick(&f, &target(), &mut claims, "run-1", &NoClaimSink).await;
match out {
TickOutcome::Idle { skipped } => assert!(matches!(
skipped[0].reason,
super::super::heal_select::Skip::UntrustedAuthor { .. }
)),
other => panic!("expected idle, got {other:?}"),
}
assert_eq!(claims.held_by("acme/widgets", 1), None);
assert!(!claims.attempts().is_empty(), "no failure was recorded");
let out2 = tick(&f, &target(), &mut claims, "run-2", &NoClaimSink).await;
match out2 {
TickOutcome::Idle { skipped } => assert!(
matches!(
skipped[0].reason,
super::super::heal_select::Skip::RecentlyFailed { .. }
),
"the second tick re-selected the item: {:?}",
skipped[0].reason
),
other => panic!("expected the item to be held back, got {other:?}"),
}
}
#[tokio::test]
async fn a_stale_refusal_becomes_eligible_again_once_its_backoff_passes() {
let mut claims = ClaimStore::new();
claims.record_failure("acme/widgets", 5, "transient", 0);
let (_, f) = io(Fake {
candidates: vec![item()],
intent: Some("fix it".into()),
now: super::super::heal_claims::BACKOFF_BASE_MS + 1,
..Default::default()
});
let out = tick(&f, &target(), &mut claims, "run-1", &NoClaimSink).await;
assert!(
!matches!(out, TickOutcome::Idle { .. }),
"an expired backoff must release the item, got {out:?}"
);
}
#[tokio::test]
async fn an_item_that_vanished_is_not_recorded_as_a_failure() {
struct Vanished;
#[async_trait::async_trait]
impl TickIo for Vanished {
async fn candidates(&self, _t: &HealTarget) -> Result<Vec<Candidate>, String> {
Ok(vec![item()])
}
async fn open_prs(&self, _t: &HealTarget) -> Result<Vec<RawPullRequest>, String> {
Ok(vec![])
}
async fn intent_for(&self, _i: &Candidate) -> Result<Intent, String> {
Ok(Intent::Gone)
}
fn redact(&self, t: &str) -> String {
t.to_string()
}
async fn run_coder(
&self,
_t: &HealTarget,
_i: &Candidate,
_s: &SessionSeed,
) -> Result<Attempt, RunFailure> {
panic!("a vanished item must not reach the coder")
}
async fn deliver(
&self,
_t: &HealTarget,
_i: &Candidate,
_s: &str,
_g: &GateOutcome,
) -> Result<PrDeliveryOutcome, DeliverRefusal> {
panic!("a vanished item must not be delivered")
}
async fn abandon(&self, _s: &str) {}
async fn comment(&self, _i: &Candidate, _t: &str) -> Result<(), String> {
Ok(())
}
fn now_ms(&self) -> u64 {
1_000_000
}
}
let io: Arc<dyn TickIo> = Arc::new(Vanished);
let mut claims = ClaimStore::new();
let out = tick(&io, &target(), &mut claims, "run-1", &NoClaimSink).await;
match out {
TickOutcome::Idle { skipped } => {
assert_eq!(skipped[0].reason, super::super::heal_select::Skip::Gone)
}
other => panic!("expected idle, got {other:?}"),
}
assert!(
claims.attempts().is_empty(),
"a vanished item leaves no backoff against an issue nobody can see"
);
}
#[tokio::test]
async fn a_stale_tier_reports_as_not_cleared_not_as_untrusted() {
struct Stale;
#[async_trait::async_trait]
impl TickIo for Stale {
async fn candidates(&self, _t: &HealTarget) -> Result<Vec<Candidate>, String> {
Ok(vec![item()])
}
async fn open_prs(&self, _t: &HealTarget) -> Result<Vec<RawPullRequest>, String> {
Ok(vec![])
}
async fn intent_for(&self, _i: &Candidate) -> Result<Intent, String> {
Ok(Intent::Refused {
tier: ProvenanceTier::Maintainer,
stale: true,
})
}
fn redact(&self, t: &str) -> String {
t.to_string()
}
async fn run_coder(
&self,
_t: &HealTarget,
_i: &Candidate,
_s: &SessionSeed,
) -> Result<Attempt, RunFailure> {
panic!("unreached")
}
async fn deliver(
&self,
_t: &HealTarget,
_i: &Candidate,
_s: &str,
_g: &GateOutcome,
) -> Result<PrDeliveryOutcome, DeliverRefusal> {
panic!("unreached")
}
async fn abandon(&self, _s: &str) {}
async fn comment(&self, _i: &Candidate, _t: &str) -> Result<(), String> {
Ok(())
}
fn now_ms(&self) -> u64 {
1_000_000
}
}
let io: Arc<dyn TickIo> = Arc::new(Stale);
let mut claims = ClaimStore::new();
match tick(&io, &target(), &mut claims, "run-1", &NoClaimSink).await {
TickOutcome::Idle { skipped } => assert_eq!(
skipped[0].reason,
super::super::heal_select::Skip::IntentNotCleared
),
other => panic!("expected idle, got {other:?}"),
}
assert!(!claims.attempts().is_empty());
}
#[tokio::test]
async fn a_failed_coder_run_still_closes_its_session() {
let (typed, f) = io(Fake {
candidates: vec![item()],
intent: Some("fix it".into()),
coder_err: Some("the worktree is unchanged".into()),
..Default::default()
});
let mut claims = ClaimStore::new();
let out = tick(&f, &target(), &mut claims, "run-1", &NoClaimSink).await;
assert!(matches!(out, TickOutcome::Failed { .. }));
assert!(
calls_of(&typed).contains(&"abandon".to_string()),
"the session was left non-terminal, holding a worktree: {:?}",
calls_of(&typed)
);
}
#[tokio::test]
async fn no_independent_coder_does_not_penalize_or_comment_on_the_item() {
let (typed, f) = io(Fake {
candidates: vec![item()],
intent: Some("fix it".into()),
coder_err: Some("no independent coder model is available; configure heal.toml".into()),
coder_configuration_error: true,
..Default::default()
});
let mut claims = ClaimStore::new();
let out = tick(&f, &target(), &mut claims, "run-1", &NoClaimSink).await;
assert!(matches!(out, TickOutcome::Failed { .. }));
assert!(claims.attempts().is_empty(), "item gained failure backoff");
assert_eq!(claims.held_by("acme/widgets", 5), None);
assert_eq!(
calls_of(&typed),
vec!["candidates", "intent", "coder", "abandon"],
"configuration failure must not post on the backlog item"
);
}
#[tokio::test]
async fn an_empty_queue_is_idle_not_failure() {
let (_, f) = io(Fake {
intent: Some("fix it".into()),
..Default::default()
});
let mut claims = ClaimStore::new();
let out = tick(&f, &target(), &mut claims, "run-1", &NoClaimSink).await;
assert!(matches!(out, TickOutcome::Idle { .. }));
}
#[tokio::test]
async fn a_watch_only_target_spends_no_api_call() {
let (typed, arc) = io(Fake {
candidates: vec![item()],
intent: Some("fix it".into()),
..Default::default()
});
let mut t = target();
t.checkout = None;
let out = tick(&arc, &t, &mut claims_new(), "run-1", &NoClaimSink).await;
assert!(matches!(out, TickOutcome::Idle { .. }));
assert!(calls_of(&typed).is_empty());
}
fn claims_new() -> ClaimStore {
ClaimStore::new()
}
#[tokio::test]
async fn an_invalid_repo_spec_fails_rather_than_idling() {
let (_, f) = io(Fake::default());
let mut t = target();
t.repo = "not-a-spec".into();
let out = tick(&f, &t, &mut claims_new(), "run-1", &NoClaimSink).await;
assert!(matches!(out, TickOutcome::Failed { .. }));
}
#[tokio::test]
async fn unavailable_ci_keeps_delivery_claim_without_failure_backoff() {
let (typed, arc) = io(Fake {
candidates: vec![item()],
intent: Some("fix parser".into()),
ci_unavailable: true,
..Default::default()
});
let mut claims = ClaimStore::new();
let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
assert!(
matches!(out, TickOutcome::Opened { ref delivery, .. } if delivery.contains("CI unavailable"))
);
assert_eq!(claims.held_by("acme/widgets", 5), Some("run-1"));
assert!(claims.attempts().is_empty());
assert!(!calls_of(&typed).contains(&"abandon".to_string()));
let again = tick(&arc, &target(), &mut claims, "run-2", &NoClaimSink).await;
assert!(matches!(again, TickOutcome::Idle { .. }));
assert_eq!(
calls_of(&typed)
.iter()
.filter(|c| c.as_str() == "deliver")
.count(),
1
);
}
#[tokio::test]
async fn a_happy_tick_claims_then_works_then_opens() {
let (typed, arc) = io(Fake {
candidates: vec![item()],
intent: Some("fix the parser".into()),
..Default::default()
});
let mut claims = ClaimStore::new();
let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
match out {
TickOutcome::Opened {
number,
pr_url,
ci,
delivery,
..
} => {
assert_eq!(number, 5);
assert!(pr_url.contains("pr/1"));
assert_eq!(ci.head_sha, "head123");
assert_eq!(ci.state, super::super::merge::CiState::Green);
assert_eq!(
delivery,
"delivered with green checks and ready for review at head123"
);
}
other => panic!("expected Opened, got {other:?}"),
}
assert_eq!(claims.held_by("acme/widgets", 5), Some("run-1"));
let calls = calls_of(&typed);
let claim_before_work = calls.iter().position(|c| c == "coder").unwrap();
assert!(
calls[..claim_before_work].contains(&"intent".to_string()),
"intent is cleared before the coder runs: {calls:?}"
);
}
#[tokio::test]
async fn an_uncleared_intent_releases_the_claim() {
let (typed, arc) = io(Fake {
candidates: vec![item()],
intent: None,
..Default::default()
});
let mut claims = ClaimStore::new();
let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
assert!(matches!(out, TickOutcome::Idle { .. }));
assert_eq!(claims.held_by("acme/widgets", 5), None, "claim released");
assert!(
!calls_of(&typed).contains(&"coder".to_string()),
"the coder must not run on uncleared intent"
);
}
#[tokio::test]
async fn a_rejected_gate_comments_and_releases() {
let (typed, arc) = io(Fake {
candidates: vec![item()],
intent: Some("fix it".into()),
attempt: Some(Attempt {
contract_passed: true,
contract_detail: "green".into(),
panel_size: 3,
verdicts: vec![
Verdict {
model: "a".into(),
pass: true,
reason: "ok".into(),
},
Verdict {
model: "b".into(),
pass: false,
reason: "changes unrelated behaviour".into(),
},
Verdict {
model: "c".into(),
pass: false,
reason: "scope".into(),
},
],
unreachable: vec![],
session_id: "coder-e2e-1".into(),
}),
..Default::default()
});
let mut claims = ClaimStore::new();
let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
assert!(matches!(out, TickOutcome::Rejected { .. }));
assert_eq!(claims.held_by("acme/widgets", 5), None);
let calls = calls_of(&typed);
assert!(
calls.contains(&"comment".to_string()),
"must explain itself"
);
assert!(
!calls.contains(&"deliver".to_string()),
"a rejected change must not be opened"
);
}
#[tokio::test]
async fn a_failed_coder_releases_the_claim() {
let (_, arc) = io(Fake {
candidates: vec![item()],
intent: Some("fix it".into()),
coder_err: Some("worktree gone".into()),
..Default::default()
});
let mut claims = ClaimStore::new();
let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
assert!(matches!(out, TickOutcome::Failed { .. }));
assert_eq!(claims.held_by("acme/widgets", 5), None);
}
#[tokio::test]
async fn a_failed_pr_open_releases_so_a_later_tick_can_retry() {
let (_, arc) = io(Fake {
candidates: vec![item()],
intent: Some("fix it".into()),
pr_err: Some("gh auth expired".into()),
..Default::default()
});
let mut claims = ClaimStore::new();
let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
assert!(matches!(out, TickOutcome::Failed { .. }));
assert_eq!(
claims.held_by("acme/widgets", 5),
None,
"otherwise the item sits claimed and invisible"
);
}
#[tokio::test]
async fn an_item_claimed_by_another_run_is_left_alone() {
let (typed, arc) = io(Fake {
candidates: vec![item()],
intent: Some("fix it".into()),
..Default::default()
});
let mut claims = ClaimStore::new();
claims
.claim("acme/widgets", 5, "other-run", 10 * CLAIM_TTL_MS)
.unwrap();
let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
assert!(matches!(out, TickOutcome::Idle { .. }));
assert_eq!(claims.held_by("acme/widgets", 5), Some("other-run"));
assert!(!calls_of(&typed).contains(&"coder".to_string()));
}
#[tokio::test]
async fn an_expired_claim_lets_a_later_tick_pick_it_up() {
let (_, arc) = io(Fake {
candidates: vec![item()],
intent: Some("fix it".into()),
..Default::default()
});
let mut claims = ClaimStore::new();
claims.claim("acme/widgets", 5, "dead-run", 0).unwrap();
let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
assert!(matches!(out, TickOutcome::Opened { .. }));
}
#[tokio::test]
async fn an_issue_already_covered_is_not_worked_twice() {
let (_, arc) = io(Fake {
candidates: vec![item()],
prs: vec![RawPullRequest::new(
"acme/widgets",
9,
"someone",
"fix",
"closes #5",
vec![],
ChecksState::Passing,
"",
false,
)],
intent: Some("fix it".into()),
..Default::default()
});
let out = tick(&arc, &target(), &mut claims_new(), "run-1", &NoClaimSink).await;
assert!(matches!(out, TickOutcome::Idle { .. }));
}
#[test]
fn a_project_target_and_a_local_target_map_to_different_coder_arguments() {
let (repo, project) = coder_target(&target()).unwrap();
assert!(repo.is_none() && project.as_deref() == Some("widgets"));
let mut t = target();
t.checkout = Some(Checkout::Local("/tmp/x".into()));
let (repo, project) = coder_target(&t).unwrap();
assert!(repo.is_some() && project.is_none());
}
#[tokio::test]
async fn a_rejected_item_is_not_retried_on_the_very_next_tick() {
let (_, arc) = io(Fake {
candidates: vec![item()],
intent: Some("fix it".into()),
attempt: Some(Attempt {
contract_passed: true,
contract_detail: "green".into(),
panel_size: 3,
verdicts: vec![
Verdict {
model: "a".into(),
pass: false,
reason: "no".into(),
},
Verdict {
model: "b".into(),
pass: false,
reason: "no".into(),
},
],
unreachable: vec![],
session_id: "coder-b".into(),
}),
..Default::default()
});
let mut claims = ClaimStore::new();
let first = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
assert!(matches!(first, TickOutcome::Rejected { .. }));
assert_eq!(claims.attempts().len(), 1, "the failure is remembered");
let a = &claims.attempts()[&crate::coder::heal_select::claim_key("acme/widgets", 5)];
assert!(!a.ready(arc.now_ms()), "not eligible again immediately");
}
#[tokio::test]
async fn a_caller_cannot_mint_approval_it_did_not_earn() {
let (_, arc) = io(Fake {
candidates: vec![item()],
intent: Some("fix it".into()),
attempt: Some(Attempt {
contract_passed: false,
contract_detail: "cargo test failed".into(),
panel_size: 3,
verdicts: vec![
Verdict {
model: "a".into(),
pass: true,
reason: "lgtm".into(),
},
Verdict {
model: "b".into(),
pass: true,
reason: "lgtm".into(),
},
Verdict {
model: "c".into(),
pass: true,
reason: "lgtm".into(),
},
],
unreachable: vec![],
session_id: "coder-b".into(),
}),
..Default::default()
});
let out = tick(
&arc,
&target(),
&mut ClaimStore::new(),
"run-1",
&NoClaimSink,
)
.await;
match out {
TickOutcome::Rejected { gate, .. } => {
assert!(gate.contains("contract"), "{gate}");
}
other => panic!("a red contract must not open a PR: {other:?}"),
}
}
#[tokio::test]
async fn a_success_clears_the_failure_history() {
let (_, arc) = io(Fake {
candidates: vec![item()],
intent: Some("fix it".into()),
..Default::default()
});
let mut claims = ClaimStore::new();
claims.record_failure("acme/widgets", 5, "earlier", 0);
let out = tick(&arc, &target(), &mut claims, "run-1", &NoClaimSink).await;
assert!(matches!(out, TickOutcome::Opened { .. }));
assert!(claims.attempts().is_empty(), "a later failure starts clean");
}
#[tokio::test]
async fn every_stop_says_why_on_the_item() {
let (typed, arc) = io(Fake {
candidates: vec![item()],
intent: Some("fix it".into()),
pr_err: Some("gh auth expired".into()),
..Default::default()
});
let out = tick(
&arc,
&target(),
&mut ClaimStore::new(),
"run-1",
&NoClaimSink,
)
.await;
assert!(matches!(out, TickOutcome::Failed { .. }));
assert!(
calls_of(&typed).contains(&"comment".to_string()),
"a silent failure teaches people to ignore the loop"
);
}
#[tokio::test]
async fn a_watch_only_target_reports_why_it_did_nothing() {
let (_, arc) = io(Fake::default());
let mut t = target();
t.checkout = None;
match tick(&arc, &t, &mut ClaimStore::new(), "run-1", &NoClaimSink).await {
TickOutcome::Idle { skipped } => {
assert_eq!(skipped.len(), 1);
assert_eq!(
skipped[0].reason,
crate::coder::heal_select::Skip::WatchOnly
);
}
other => panic!("expected an explained idle, got {other:?}"),
}
}
#[tokio::test]
async fn an_uncleared_intent_reports_clearance_not_authorisation() {
let (_, arc) = io(Fake {
candidates: vec![item()],
intent: None,
intent_stale: true,
..Default::default()
});
match tick(
&arc,
&target(),
&mut ClaimStore::new(),
"run-1",
&NoClaimSink,
)
.await
{
TickOutcome::Idle { skipped } => {
assert_eq!(
skipped[0].reason,
crate::coder::heal_select::Skip::IntentNotCleared
);
}
other => panic!("expected an explained idle, got {other:?}"),
}
}
}