use std::collections::{BTreeMap, BTreeSet};
use std::time::{Duration, Instant};
use argonauts_core::TaskId;
use argonauts_core::display_names::DisplayNames;
use argonauts_core::events::RunEventKind;
use argonauts_core::events::agent_status::{JobKeeper, JobRef, JobWait};
use argonauts_core::events::run::RunMode;
use argonauts_core::events::session::{EndReason, loaded_words, stuck_words};
use argonauts_core::events::workflow::{RunNote, WorkflowEvent};
use argonauts_core::limits::{AgentHealth, HealthState, ModelState, TEST_TASK_SUCCEEDED, Trouble};
use argonauts_core::log::{NO_SECOND_TRY, NOBODY_TO_FALL_BACK_TO, SAME_CLI_IS_ON};
use argonauts_core::names::{AgentId, ModelRef, RoleName, WorkflowId};
use argonauts_core::registry::listed::{self, nearest_listed, other_models, runs_in_place};
use argonauts_core::session_runner::RunnerKind;
use argonauts_core::settings::Settings;
use argonauts_core::settings::fallback::{
AppliedFallback, FallbackEntry, FallbackMode, Retry, SameStep,
};
use argonauts_core::steps::StepName;
use argonauts_core::{ErrorCategory, Note, NoteLevel};
use time::OffsetDateTime;
use crate::engine::host::{Host, round_window};
use crate::engine::shared::lock;
use crate::error::{Beside, EngineError, EngineResult, Stall, StalledJob};
use crate::fallback::rules;
use crate::roles::{self, display_name};
use crate::state::RunState;
use crate::state::sessions::SessionRecord;
use crate::workflow::StepAt;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Job {
pub at: Option<StepAt>,
pub role: RoleName,
pub step: Option<StepName>,
pub task: Option<TaskId>,
pub independent_of: Vec<ModelRef>,
pub isolated: bool,
}
impl Job {
fn same_place(&self, other: &Self) -> bool {
self.at == other.at
&& self.role == other.role
&& self.step == other.step
&& self.task == other.task
}
fn named(&self) -> JobRef {
JobRef {
step: self.at.as_ref().map(|at| at.step.clone()),
round: self.at.as_ref().map(|at| at.round),
role: self.role,
does: self.step,
task: self.task.clone(),
}
}
}
struct JobRules {
applied: AppliedFallback,
first: Option<ModelRef>,
anyone: bool,
second_in_step: bool,
}
impl JobRules {
fn of(host: &Host, job: &Job, agent_id: &AgentId) -> Self {
let mut applied = rules::applied(host, job.role, agent_id);
let freed = host.read(|state| {
state
.run
.freed(&job.named())
.map(Option::<&ModelRef>::cloned)
});
if freed.is_some() {
applied.same_cli = false;
}
Self {
applied,
first: freed.flatten(),
anyone: false,
second_in_step: false,
}
}
}
#[derive(Debug, Default)]
pub(crate) struct Memory {
logins: BTreeMap<ModelRef, Login>,
taken: Vec<Taken>,
said: BTreeSet<(ModelRef, ModelRef)>,
keeps_failing: BTreeMap<ModelRef, Failing>,
wont_start: BTreeMap<AgentId, Failing>,
followed: BTreeSet<Followed>,
listed: BTreeMap<ModelRef, ModelRef>,
said_listed: BTreeSet<String>,
started_crashing: BTreeMap<AgentId, String>,
lost_the_network: BTreeSet<u64>,
following_the_network: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Followed {
Cli(AgentId),
Task(TaskId),
}
#[derive(Debug, Clone)]
struct Failing {
words: String,
after: u64,
at: OffsetDateTime,
}
impl Failing {
fn holds(&self, resumed: u64, health: &AgentHealth) -> bool {
let tested_since = health.state == HealthState::Healthy
&& health.since > self.at
&& health.reason.as_deref() == Some(TEST_TASK_SUCCEEDED);
self.after == resumed && !tested_since
}
}
#[derive(Debug, Clone)]
struct Taken {
job: Job,
asked: ModelRef,
by: ModelRef,
}
#[derive(Debug, Clone, Copy)]
struct Login {
at: Instant,
logged_out: bool,
}
impl Memory {
pub(crate) fn follow(&mut self, question: Followed) -> bool {
self.followed.insert(question)
}
pub(crate) fn unfollow(&mut self, question: &Followed) {
self.followed.remove(question);
}
fn failing(&self, worker: &ModelRef) -> Option<&Failing> {
let own = self.keeps_failing.get(worker);
own.or_else(|| self.wont_start.get(&worker.agent_id()))
}
fn logged_out(&self, worker: &ModelRef) -> bool {
self.logins
.get(worker)
.is_some_and(|login| login.logged_out)
}
}
enum Why {
Ended(EndReason),
EndedAgain(EndReason),
LoggedOut(Option<EndReason>),
Unusable(&'static str),
ModelOut(&'static str),
KeepsFailing(String),
}
impl Why {
fn model_only(&self) -> bool {
match self {
Self::Ended(reason) | Self::EndedAgain(reason) => {
is_outage(reason) || is_refused_model(reason)
}
Self::ModelOut(_) => true,
Self::LoggedOut(_) | Self::Unusable(_) | Self::KeepsFailing(_) => false,
}
}
fn signed_out(&self) -> bool {
matches!(
self,
Self::LoggedOut(_) | Self::Ended(EndReason::Auth { .. })
)
}
}
fn is_outage(reason: &EndReason) -> bool {
matches!(
reason,
EndReason::CliError {
category: ErrorCategory::Overloaded | ErrorCategory::Server,
..
}
)
}
fn is_refused_model(reason: &EndReason) -> bool {
matches!(
reason,
EndReason::ModelUnavailable { .. }
| EndReason::CliError {
category: ErrorCategory::ModelUnavailable,
..
}
)
}
impl Why {
fn refused_model(&self) -> bool {
matches!(self, Self::Ended(reason) | Self::EndedAgain(reason) if is_refused_model(reason))
}
}
pub(crate) async fn instead_of(
host: &Host,
asked: &ModelRef,
job: &Job,
) -> EngineResult<Option<ModelRef>> {
let why = if is_logged_out(host, asked).await {
Why::LoggedOut(None)
} else if let Some(words) = unusable(host, asked) {
Why::Unusable(words)
} else if let Some(words) = model_out(host, asked) {
Why::ModelOut(words)
} else if let Some(words) = keeps_failing(host, asked) {
let why = Why::KeepsFailing(words);
return Ok(fall_back(host, job, asked, asked, &[asked], &why).ok());
} else {
return Ok(None);
};
fall_back(host, job, asked, asked, &[asked], &why).map(Some)
}
pub(crate) fn listed(host: &Host, worker: &ModelRef) -> ModelRef {
let pinned = pinned(host, worker);
let Some(nearest) = nearest_listed(host.services().registry.as_ref(), &pinned) else {
return worker.clone();
};
lock(&host.shared().job_fallbacks)
.listed
.insert(nearest.clone(), pinned);
nearest
}
fn pinned(host: &Host, worker: &ModelRef) -> ModelRef {
if worker.model.is_some() {
return worker.clone();
}
let set = host.settings().agents.get(&worker.agent);
ModelRef::new(
worker.agent.clone(),
set.and_then(|agent| agent.model.clone()),
)
}
pub(crate) fn say_listed(
host: &Host,
label: &str,
worker: &ModelRef,
task: Option<&TaskId>,
) -> EngineResult<()> {
let gone = {
let mut memory = lock(&host.shared().job_fallbacks);
let Some(gone) = memory.listed.get(worker).cloned() else {
return Ok(());
};
if !memory.said_listed.insert(label.to_owned()) {
return Ok(());
}
gone
};
let registry = host.services().registry.as_ref();
note(host, task, runs_in_place(registry, label, &gone, worker))
}
pub(crate) fn model_name(host: &Host, model: &ModelRef) -> Option<String> {
listed::model_name(host.services().registry.as_ref(), model)
}
fn keeps_failing(host: &Host, worker: &ModelRef) -> Option<String> {
let since = host.read(|state| state.run.last_resumed).unwrap_or(0);
let failing = lock(&host.shared().job_fallbacks)
.failing(worker)
.cloned()?;
let health = host.services().health.state(&worker.agent_id());
failing.holds(since, &health).then_some(failing.words)
}
fn remember_failed_start(host: &Host, worker: &ModelRef, reason: &EndReason) {
if !matches!(reason, EndReason::StartFailed { .. }) {
return;
}
let failing = Failing {
words: in_words(reason),
after: host.read(|state| state.run.last_resumed).unwrap_or(0),
at: host.now(),
};
lock(&host.shared().job_fallbacks)
.wont_start
.insert(worker.agent_id(), failing);
}
const STARTUP_CRASH: Duration = Duration::from_secs(2);
fn remember_startup_crash(
host: &Host,
worker: &ModelRef,
record: &SessionRecord,
) -> EngineResult<()> {
let Some(end) = &record.end else {
return Ok(());
};
let EndReason::Crashed { message } = &end.reason else {
lock(&host.shared().job_fallbacks)
.started_crashing
.remove(&worker.agent_id());
return Ok(());
};
let at_start =
end.cli_session.is_none() && Duration::from_millis(end.duration_ms) < STARTUP_CRASH;
let message = first_line(message).to_owned();
let twice = {
let mut memory = lock(&host.shared().job_fallbacks);
if !at_start {
memory.started_crashing.remove(&worker.agent_id());
return Ok(());
}
let before = memory
.started_crashing
.insert(worker.agent_id(), message.clone());
let twice = before.as_deref() == Some(message.as_str());
if twice {
let words = if message.is_empty() {
"crashed twice as it started".to_owned()
} else {
format!("crashed twice as it started: \"{message}\"")
};
let failing = Failing {
words,
after: host.read(|state| state.run.last_resumed).unwrap_or(0),
at: host.now(),
};
memory.wont_start.insert(worker.agent_id(), failing);
}
twice
};
if !twice {
return Ok(());
}
let cli = DisplayNames::default().cli(worker.agent.as_str());
let how = if message.is_empty() {
String::new()
} else {
format!(": \"{message}\"")
};
note(
host,
record.task.as_ref(),
format!("{cli} is out for this run: it crashed twice as it started{how}"),
)
}
pub(crate) fn known_out(host: &Host, worker: &ModelRef) -> bool {
let logged_out = lock(&host.shared().job_fallbacks).logged_out(worker);
logged_out
|| keeps_failing(host, worker).is_some()
|| start_refusal(host, worker).is_some()
|| unusable(host, worker).is_some()
|| model_out(host, worker).is_some()
}
fn start_refusal(host: &Host, worker: &ModelRef) -> Option<String> {
host.services().workers.get(worker).ok()?.start_refusal()
}
pub(crate) async fn is_out(host: &Host, worker: &ModelRef) -> bool {
is_logged_out(host, worker).await
|| unusable(host, worker).is_some()
|| model_out(host, worker).is_some()
}
pub(crate) fn hold(host: &Host, job: &Job, holder: &ModelRef) {
let mut memory = lock(&host.shared().job_fallbacks);
let held = memory
.taken
.iter()
.any(|taken| taken.by == *holder && taken.asked == *holder && taken.job.same_place(job));
if !held {
memory.taken.push(Taken {
job: job.clone(),
asked: holder.clone(),
by: holder.clone(),
});
}
}
pub(crate) fn together(
host: &Host,
job: &Job,
asked: &ModelRef,
with: &[&ModelRef],
count: usize,
) -> Vec<ModelRef> {
let rules = JobRules::of(host, job, &asked.agent_id());
if rules.applied.mode == FallbackMode::Wait {
return Vec::new();
}
let mut memory = lock(&host.shared().job_fallbacks);
let stop = Stopped {
from: asked,
failed: &[asked],
why: None,
};
let takers: Vec<ModelRef> = candidates(host, job, asked, &rules, &stop, &memory)
.into_iter()
.filter(|taker| !with.contains(&taker))
.take(count)
.collect();
for taker in &takers {
memory.taken.push(Taken {
job: job.clone(),
asked: asked.clone(),
by: taker.clone(),
});
}
takers
}
pub(crate) async fn after(
host: &Host,
job: &Job,
from: &ModelRef,
failed: &[&ModelRef],
reason: &EndReason,
) -> EngineResult<ModelRef> {
remember_failed_start(host, from, reason);
let why = if is_logged_out(host, from).await {
Why::LoggedOut(Some(reason.clone()))
} else {
Why::Ended(reason.clone())
};
fall_back(host, job, from, from, failed, &why)
}
pub(crate) async fn next(host: &Host, record: &SessionRecord, job: &Job) -> EngineResult<ModelRef> {
if failures(host, record).is_empty() {
let asked = record.asked_worker();
return Ok(instead_of(host, asked, job)
.await?
.unwrap_or_else(|| asked.clone()));
}
if let Some(again) = again(host, record).await? {
return Ok(again);
}
let failed = failures(host, record);
let current = &record.started.worker;
let reason = end_reason(record);
remember_failed_start(host, current, &reason);
let why = if is_logged_out(host, current).await {
Why::LoggedOut(Some(reason))
} else if went_again(&failed, current, &reason) {
if matches!(reason, EndReason::Crashed { .. }) {
let since = host.read(|state| state.run.last_resumed).unwrap_or(0);
let failing = Failing {
words: format!("{} twice in a row", in_words(&reason)),
after: since,
at: host.now(),
};
lock(&host.shared().job_fallbacks)
.keeps_failing
.insert(current.clone(), failing);
}
Why::EndedAgain(reason)
} else {
Why::Ended(reason)
};
fall_back(host, job, record.asked_worker(), current, &failed, &why)
}
pub(crate) async fn again(host: &Host, record: &SessionRecord) -> EngineResult<Option<ModelRef>> {
let failed = failures(host, record);
if failed.is_empty() {
return Ok(Some(record.asked_worker().clone()));
}
if missed_only_the_report(record) {
return Ok(None);
}
let current = &record.started.worker;
remember_startup_crash(host, current, record)?;
if keeps_failing(host, current).is_some() {
return Ok(None);
}
let reason = end_reason(record);
let logged_in = login_check(host, current, &reason).await;
let tries = failed.iter().filter(|worker| **worker == current).count();
let tries = u32::try_from(tries).unwrap_or(u32::MAX);
let retry = rules::applied(host, record.started.role, ¤t.agent_id()).retry;
if tries <= retry.tries && reason.may_pass() && logged_in != Some(false) {
let span = retry.wait_before(tries);
say_retry(host, record, &reason, &retry, span)?;
wait(host, span).await?;
return Ok(Some(current.clone()));
}
Ok(None)
}
pub(crate) fn still_out(host: &Host, record: &SessionRecord) -> Option<&'static str> {
let failed = failures(host, record);
let reason = end_reason(record);
went_again(&failed, &record.started.worker, &reason).then(|| passing_words(&reason).1)
}
fn went_again(failed: &[&ModelRef], current: &ModelRef, reason: &EndReason) -> bool {
reason.may_pass() && failed.iter().filter(|worker| **worker == current).count() >= 2
}
fn say_retry(
host: &Host,
record: &SessionRecord,
reason: &EndReason,
retry: &Retry,
span: Duration,
) -> EngineResult<()> {
let name = display_name(&record.started.worker);
let (stopped, _) = passing_words(reason);
let again = if span.is_zero() {
"trying again".to_owned()
} else {
format!("trying again in {}", wait_in_words(retry, span))
};
note(
host,
record.task.as_ref(),
format!("{name} {stopped} · {again}"),
)
}
fn wait_in_words(retry: &Retry, span: Duration) -> String {
if retry.wait.parse().ok().flatten() == Some(span) {
return retry.wait.0.trim().to_owned();
}
let seconds = span.as_secs();
match (seconds / 60, seconds % 60) {
(0, seconds) => format!("{seconds}s"),
(minutes, 0) => format!("{minutes}m"),
(minutes, seconds) => format!("{minutes}m {seconds}s"),
}
}
fn passing_words(reason: &EndReason) -> (&'static str, &'static str) {
const SIGN_IN: (&str, &str) = ("got a sign-in error", "Still a sign-in error");
const RATE: (&str, &str) = ("is rate limited", "Still rate limited");
match reason {
EndReason::Auth { .. } => SIGN_IN,
EndReason::RateLimited => RATE,
EndReason::StartFailed { .. } => ("couldn't start", "Still can't start"),
EndReason::CliError { category, .. } => match category {
ErrorCategory::Auth => SIGN_IN,
ErrorCategory::RateLimit => RATE,
ErrorCategory::Overloaded => ("is overloaded", "Still overloaded"),
ErrorCategory::Server | ErrorCategory::Network | ErrorCategory::Timeout => {
("couldn't be reached", "Still unreachable")
}
_ => ("gave an error", "Still failing"),
},
_ => ("crashed", "Crashed again"),
}
}
pub(crate) async fn login_check(
host: &Host,
worker: &ModelRef,
reason: &EndReason,
) -> Option<bool> {
if !reason.is_sign_in_error() {
return None;
}
let logged_in = if says_signed_out(reason) {
Some(false)
} else {
logged_in_now(host, worker).await
};
let login = Login {
at: Instant::now(),
logged_out: logged_in == Some(false),
};
lock(&host.shared().job_fallbacks)
.logins
.insert(worker.clone(), login);
logged_in
}
pub(crate) fn says_signed_out(reason: &EndReason) -> bool {
argonauts_core::sign_in::signed_out(reason, "").is_some()
}
async fn is_logged_out(host: &Host, worker: &ModelRef) -> bool {
let known = lock(&host.shared().job_fallbacks)
.logins
.get(worker)
.copied();
match known {
Some(login) if login.at.elapsed() < recheck_after(host) => return login.logged_out,
Some(login) if login.logged_out => {}
_ if !host.read(|state| last_ended_on_sign_in(state, worker)) => return false,
_ => {}
}
let logged_out = logged_in_now(host, worker).await == Some(false);
let login = Login {
at: Instant::now(),
logged_out,
};
lock(&host.shared().job_fallbacks)
.logins
.insert(worker.clone(), login);
logged_out
}
fn last_ended_on_sign_in(state: &RunState, worker: &ModelRef) -> bool {
state.sessions.sign_in_failed.get(worker) == Some(&true)
}
pub(crate) fn named_by_its_own(host: &Host, stall: &mut Stall, own: &ModelRef) {
if stall.job.asked.as_ref() == Some(own)
|| !host.read(|state| last_ended_on_sign_in(state, own))
{
return;
}
let (short, said) = failure_words(&Why::LoggedOut(None), own);
stall.job.asked = Some(own.clone());
stall.job.short = short;
stall.job.said = said;
}
async fn logged_in_now(host: &Host, worker: &ModelRef) -> Option<bool> {
let worker = host.services().workers.get(worker).ok()?;
worker.logged_in().await
}
fn recheck_after(host: &Host) -> Duration {
let cooldown = &host.settings().health.cooldown;
cooldown
.parse()
.ok()
.flatten()
.unwrap_or(Duration::from_secs(300))
}
fn unusable(host: &Host, worker: &ModelRef) -> Option<&'static str> {
let agent_id = worker.agent_id();
if rules::agent_usable(host, &agent_id) {
return None;
}
Some(match host.services().health.state(&agent_id).state {
HealthState::Down => "its agent is down",
HealthState::Testing => "its agent is being tested after an outage",
HealthState::Healthy | HealthState::Degraded => "it's out of usage until its plan resets",
})
}
fn model_out(host: &Host, worker: &ModelRef) -> Option<&'static str> {
let health = host.services().health.model(worker);
(health.state == ModelState::Out).then_some(match health.trouble {
Some(Trouble::ServerErrors) => "it keeps giving server errors",
Some(Trouble::Overload) | None => "it's overloaded",
})
}
fn failures<'r>(host: &Host, record: &'r SessionRecord) -> Vec<&'r ModelRef> {
let since = host.read(|state| state.run.last_resumed).unwrap_or(0);
let offline = lock(&host.shared().job_fallbacks).lost_the_network.clone();
record
.agent_failures
.iter()
.filter(|(_, seq)| *seq > since && !offline.contains(seq))
.map(|(worker, _)| worker)
.collect()
}
pub(crate) fn hold_against_nobody(host: &Host, record: &SessionRecord) {
if let Some((_, seq)) = record.agent_failures.last() {
lock(&host.shared().job_fallbacks)
.lost_the_network
.insert(*seq);
}
}
pub(crate) fn begin_following_the_network(host: &Host) -> bool {
let mut memory = lock(&host.shared().job_fallbacks);
!std::mem::replace(&mut memory.following_the_network, true)
}
pub(crate) fn end_following_the_network(host: &Host) {
lock(&host.shared().job_fallbacks).following_the_network = false;
}
fn missed_only_the_report(record: &SessionRecord) -> bool {
record.task.is_some()
&& record.started.step == Some(StepName::Build)
&& record.end.as_ref().is_some_and(|end| {
matches!(
end.reason,
EndReason::CliError {
category: ErrorCategory::AnswerMissed,
..
}
) && end.tool_calls > 0
&& end.cli_session.is_some()
})
}
fn end_reason(record: &SessionRecord) -> EndReason {
record.end.as_ref().map_or_else(
|| crate::engine::host::crashed_end("").reason,
|end| end.reason.clone(),
)
}
fn fall_back(
host: &Host,
job: &Job,
asked: &ModelRef,
from: &ModelRef,
failed: &[&ModelRef],
why: &Why,
) -> EngineResult<ModelRef> {
let rules = JobRules::of(host, job, &from.agent_id());
let (mut kept, mut beside) = (None, None);
if rules.applied.mode != FallbackMode::Wait {
let stop = Stopped {
from,
failed,
why: Some(why),
};
let (taker, anywhere) = {
let mut memory = lock(&host.shared().job_fallbacks);
let taker = candidates(host, job, asked, &rules, &stop, &memory)
.into_iter()
.next();
if let Some(taker) = &taker {
memory.taken.push(Taken {
job: job.clone(),
asked: asked.clone(),
by: taker.clone(),
});
}
let anywhere = without_same_cli(host, job, asked, &rules, &stop, &memory);
if taker.is_none() {
beside = beside_in_step(host, job, asked, &rules, &stop, &memory);
}
(taker, anywhere)
};
if let Some(taker) = taker {
let kept_off = anywhere
.first()
.map(ModelRef::agent_id)
.filter(|first| *first != asked.agent_id());
say_fallback(host, job, from, &taker, why, kept_off.as_ref())?;
return Ok(listed(host, &taker));
}
if let Some(taker) = builders_provider_on_autopilot(host, job, asked, &rules, &stop) {
say_builders_provider(host, job, from, &taker, why)?;
return Ok(taker);
}
kept = kept_on_its_cli(host, job, asked, anywhere)
.or_else(|| left_to_builders_provider(host, job, asked, &rules, &stop));
}
let mut stall = stalled(host, job, from, failed, why, &rules.applied, kept);
if let EngineError::Stalled(stall) = &mut stall {
stall.beside = beside;
}
Err(stall)
}
fn beside_in_step(
host: &Host,
job: &Job,
asked: &ModelRef,
rules: &JobRules,
stop: &Stopped<'_>,
memory: &Memory,
) -> Option<Beside> {
let applied = &rules.applied;
if applied.same_step == SameStep::Never || applied.same_cli {
return None;
}
let registry = host.services().registry.as_ref();
let at_work = host.read(|state| at_work_in_step(state, job, stop.from));
let mut pool: Vec<FallbackEntry> = Vec::new();
for model in &at_work {
for other in other_models(registry, model) {
let entry = FallbackEntry::Model(other);
if !at_work.contains(entry.model()) && !pool.contains(&entry) {
pool.push(entry);
}
}
}
let beside = JobRules {
applied: AppliedFallback {
candidates: pool,
same_cli: false,
..applied.clone()
},
first: None,
anyone: false,
second_in_step: true,
};
let models = candidates(host, job, asked, &beside, stop, memory);
(!models.is_empty()).then(|| Beside {
models,
ask: applied.same_step == SameStep::Ask,
})
}
fn builders_provider_on_autopilot(
host: &Host,
job: &Job,
asked: &ModelRef,
rules: &JobRules,
stop: &Stopped<'_>,
) -> Option<ModelRef> {
let on_autopilot = crate::approvals::autopilot::applies_now(
host,
argonauts_core::events::approval::ApprovalKind::Fallback,
);
if !on_autopilot {
return None;
}
let open = without_its_builders(host, job, asked)?;
let mut memory = lock(&host.shared().job_fallbacks);
let taker = candidates(host, &open, asked, rules, stop, &memory)
.into_iter()
.next()?;
memory.taken.push(Taken {
job: job.clone(),
asked: asked.clone(),
by: taker.clone(),
});
Some(taker)
}
fn say_builders_provider(
host: &Host,
job: &Job,
from: &ModelRef,
taker: &ModelRef,
why: &Why,
) -> EngineResult<()> {
let new = lock(&host.shared().job_fallbacks)
.said
.insert((from.clone(), taker.clone()));
if !new {
return Ok(());
}
let stopped = stopped_in_words(&display_name(from), why);
let text = format!(
"{stopped}, and nobody outside the builder's provider is left, so {} takes {} on \
autopilot · the merge is left to you",
display_name(taker),
job_in_words(job),
);
note(
host,
job.task.as_ref(),
argonauts_core::text::sentence_case(&text),
)
}
fn without_its_builders(host: &Host, job: &Job, asked: &ModelRef) -> Option<Job> {
let reviews_a_task =
job.task.is_some() && matches!(job.role, RoleName::Reviewer | RoleName::Judge);
if job.independent_of.is_empty() || !reviews_a_task {
return None;
}
if !host.read(|state| others_in_step(state, job, asked).is_empty()) {
return None;
}
Some(Job {
independent_of: Vec::new(),
..job.clone()
})
}
fn left_to_builders_provider(
host: &Host,
job: &Job,
asked: &ModelRef,
rules: &JobRules,
stop: &Stopped<'_>,
) -> Option<JobWait> {
let open = without_its_builders(host, job, asked)?;
let mut elsewhere = {
let memory = lock(&host.shared().job_fallbacks);
let by_rules = candidates(host, &open, asked, rules, stop, &memory);
if by_rules.is_empty() {
return None;
}
let anyone = JobRules {
applied: AppliedFallback {
same_cli: false,
..rules.applied.clone()
},
first: None,
anyone: true,
second_in_step: false,
};
let rest = candidates(host, &open, asked, &anyone, stop, &memory);
let mut elsewhere = by_rules;
elsewhere.extend(rest);
elsewhere
};
let mut seen = BTreeSet::new();
elsewhere.retain(|model| seen.insert(model.clone()));
let from = display_name(stop.from);
let why = match stop.why {
Some(why) => stopped_in_words(&from, why),
None => format!("{from} stopped"),
};
Some(JobWait {
job: job.named(),
stopped: asked.clone(),
elsewhere,
title: job_title(host, job),
kept_by: JobKeeper::BuildersProvider {
builders: job.independent_of.clone(),
why,
},
})
}
fn job_title(host: &Host, job: &Job) -> String {
let title = host.read(|state| {
let task = job.task.as_ref().and_then(|task| state.tasks.get(task));
task.map(|record| record.spec.title.clone())
});
title.unwrap_or_else(|| argonauts_core::text::sentence_case(&job_in_words(job)))
}
pub(crate) fn work_of(builders: &[ModelRef]) -> String {
let names: Vec<String> = builders.iter().map(roles::short_name).collect();
let mut unique: Vec<String> = Vec::new();
for name in names {
if !unique.contains(&name) {
unique.push(name);
}
}
match unique.as_slice() {
[] => "the builder's work".to_owned(),
[one] => format!("{one}'s work"),
[rest @ .., last] => format!("the work of {} and {last}", rest.join(", ")),
}
}
pub(crate) fn verb_of(role: RoleName) -> &'static str {
match role {
RoleName::Judge => "judge",
_ => "review",
}
}
fn without_same_cli(
host: &Host,
job: &Job,
asked: &ModelRef,
rules: &JobRules,
stop: &Stopped<'_>,
memory: &Memory,
) -> Vec<ModelRef> {
if !rules.applied.same_cli {
return Vec::new();
}
let anywhere = JobRules {
applied: AppliedFallback {
same_cli: false,
..rules.applied.clone()
},
first: None,
anyone: false,
second_in_step: false,
};
candidates(host, job, asked, &anywhere, stop, memory)
}
fn kept_on_its_cli(
host: &Host,
job: &Job,
asked: &ModelRef,
anywhere: Vec<ModelRef>,
) -> Option<JobWait> {
let elsewhere: Vec<ModelRef> = anywhere
.into_iter()
.filter(|model| model.agent != asked.agent)
.collect();
if elsewhere.is_empty() {
return None;
}
Some(JobWait {
job: job.named(),
stopped: asked.clone(),
elsewhere,
title: job_title(host, job),
kept_by: JobKeeper::SameCli,
})
}
struct Stopped<'a> {
from: &'a ModelRef,
failed: &'a [&'a ModelRef],
why: Option<&'a Why>,
}
fn candidates(
host: &Host,
job: &Job,
asked: &ModelRef,
rules: &JobRules,
stop: &Stopped<'_>,
memory: &Memory,
) -> Vec<ModelRef> {
let services = host.services();
let applied = &rules.applied;
let (listed, failed) = (&applied.candidates, stop.failed);
let leaked = matches!(
stop.why,
Some(Why::Ended(EndReason::IsolationBroken { .. }))
)
.then(|| stop.from.agent_id());
let model_only = stop.why.is_some_and(Why::model_only);
let stopped: Vec<_> = failed
.iter()
.filter(|model| !(model_only && **model == stop.from))
.map(|model| model.agent_id())
.collect();
let pool: Vec<ModelRef> = if applied.same_cli {
let whole_cli = stop.why.is_some() && !model_only;
let mut pool: Vec<ModelRef> = listed.iter().map(|entry| entry.model().clone()).collect();
pool.extend(services.workers.available());
pool.retain(|model| model.agent == asked.agent && !whole_cli);
pool
} else if listed.is_empty() && host.shared().mode == RunMode::Captain && !rules.anyone {
Vec::new()
} else if listed.is_empty() || rules.anyone {
let available = services.workers.available();
let mut pool: Vec<ModelRef> = listed.iter().map(|entry| entry.model().clone()).collect();
pool.extend(
host.settings()
.crew
.members()
.into_iter()
.filter(|model| available.contains(model)),
);
pool.extend(available);
pool.retain(|model| {
!stopped.contains(&model.agent_id())
&& services
.workers
.get(model)
.is_ok_and(|agent| agent.kind() == RunnerKind::Cli)
});
pool
} else {
listed.iter().map(|entry| entry.model().clone()).collect()
};
let refused = pinned(host, stop.from);
let siblings = match stop.why {
Some(why) if why.refused_model() && refused.model.is_some() => {
other_models(services.registry.as_ref(), &refused)
}
_ => Vec::new(),
};
let pool = rules.first.iter().cloned().chain(siblings).chain(pool);
let (worker, others) = host.read(|state| {
let worker = job
.task
.as_ref()
.and_then(|task| state.tasks.get(task).and_then(rules::worker));
(worker, others_in_step(state, job, asked))
});
let provider = |model: &ModelRef| services.registry.provider(model);
let resumed = host.read(|state| state.run.last_resumed).unwrap_or(0);
let crashing = |model: &ModelRef| {
memory.failing(model).is_some_and(|failing| {
failing.holds(resumed, &services.health.state(&model.agent_id()))
})
};
let providers: Vec<String> = job.independent_of.iter().map(&provider).collect();
let failing = stop
.why
.filter(|why| why.model_only())
.filter(|_| services.health.state(&stop.from.agent_id()).state == HealthState::Down)
.map(|_| provider(stop.from));
let chosen = |model: &ModelRef| rules.first.as_ref() == Some(model);
let mut kept = Vec::new();
for model in pool {
let in_step = others.contains(&model)
|| (!rules.second_in_step
&& model.agent != asked.agent
&& others.iter().any(|other| other.agent == model.agent));
let elsewhere = in_step
|| memory.taken.iter().any(|taken| {
taken.by == model && taken.asked != *asked && taken.job.same_place(job)
});
let usable = !failed.contains(&&model)
&& !kept.contains(&model)
&& !elsewhere
&& !memory.logged_out(&model)
&& !crashing(&model)
&& (chosen(&model) || !providers.contains(&provider(&model)))
&& services
.workers
.get(&model)
.is_ok_and(|runner| runner.start_refusal().is_none())
&& rules::takes_work(host, &model)
&& !(model.agent != stop.from.agent && failing == Some(provider(&model)))
&& roles::conflict(job.role, &model, worker.as_ref(), &provider).is_none()
&& leaked.as_ref() != Some(&model.agent_id())
&& (!job.isolated || services.workers.isolates(&model));
if usable {
kept.push(model);
}
}
kept
}
fn others_in_step(state: &RunState, job: &Job, asked: &ModelRef) -> Vec<ModelRef> {
let (from, until) = round_window(state, job.at.as_ref());
let step = job.at.as_ref().map(|at| &at.step);
let mut others = Vec::new();
for session in state.sessions.by_key.values() {
let here = session.since >= from
&& until.is_none_or(|until| session.since < until)
&& session.started.workflow_step.as_ref() == step
&& session.started.role == job.role
&& session.started.step == job.step
&& session.task == job.task
&& session.asked_worker() != asked;
if here {
others.push(session.asked_worker().clone());
others.push(session.started.worker.clone());
}
}
others
}
fn say_fallback(
host: &Host,
job: &Job,
from: &ModelRef,
taker: &ModelRef,
why: &Why,
kept_off: Option<&AgentId>,
) -> EngineResult<()> {
let new = lock(&host.shared().job_fallbacks)
.said
.insert((from.clone(), taker.clone()));
let made_before = host.read(|state| {
state
.sessions
.by_key
.values()
.any(|session| session.asked_worker() == from && session.started.worker == *taker)
});
if !new || made_before {
return Ok(());
}
let (from_name, taker_name) = (display_name(from), display_name(taker));
let text = match why {
Why::LoggedOut(_) => format!(
"{from_name} says it's logged out: log in again; {taker_name} takes its roles \
meanwhile."
),
Why::Unusable(words) | Why::ModelOut(words) => {
format!("{from_name} is out ({words}), so {taker_name} takes its roles meanwhile.")
}
Why::Ended(reason) if repeats(reason) => format!(
"Fell back to {} as {} · {NO_SECOND_TRY}",
DisplayNames::default().cli(taker.agent.as_str()),
job_as_role(job),
),
Why::Ended(reason) => format!(
"{from_name}'s session as {} ended early ({}), so it fell back to {taker_name}.",
job_in_words(job),
in_words(reason),
),
Why::EndedAgain(reason) => format!(
"{} · fell back to {taker_name} as {}",
passing_words(reason).1,
job_in_words(job)
),
Why::KeepsFailing(words) => format!(
"{from_name} isn't started again this run, since {words}; {taker_name} takes its \
roles."
),
};
let text = match kept_off {
Some(other) => format!(
"{text}\n{SAME_CLI_IS_ON}{} isn't asked",
DisplayNames::default().cli(other.0.as_str())
),
None => text,
};
note(host, job.task.as_ref(), text)?;
if let Some(at) = &job.at {
let line = in_the_view(&from_name, &taker_name, Some(why), kept_off.is_some());
say_in_the_view(host, &at.step.workflow, line)?;
}
Ok(())
}
pub(crate) fn say_in_the_view(
host: &Host,
workflow: &WorkflowId,
line: String,
) -> EngineResult<()> {
let about = RunNote {
text: line,
workflow: Some(workflow.clone()),
left: false,
};
host.record(
None,
None,
RunEventKind::Workflow(WorkflowEvent::Note(about)),
)?;
Ok(())
}
fn in_the_view(from_name: &str, taker_name: &str, why: Option<&Why>, same_cli: bool) -> String {
let stopped = match why {
Some(Why::Ended(reason) | Why::EndedAgain(reason)) if reason.may_pass() => {
format!("{from_name} {}", passing_words(reason).0)
}
Some(why) => stopped_in_words(from_name, why),
None => format!("{from_name} stopped"),
};
let as_asked = if same_cli { ", as same_cli asks" } else { "" };
format!("{stopped} · fell back to {taker_name}{as_asked}")
}
pub(crate) fn task_in_the_view(
from: &ModelRef,
taker: &ModelRef,
ended: Option<&EndReason>,
same_cli: bool,
) -> String {
let why = ended
.filter(|reason| reason.is_agent_failure())
.map(|reason| Why::Ended(reason.clone()));
let same_model = taker.model.is_some() && taker == from;
let taker_name = if same_model {
format!("{} (the same model)", display_name(taker))
} else {
display_name(taker)
};
in_the_view(&display_name(from), &taker_name, why.as_ref(), same_cli)
}
fn stopped_in_words(from_name: &str, why: &Why) -> String {
match why {
Why::Ended(reason) | Why::EndedAgain(reason) | Why::LoggedOut(Some(reason)) => {
format!("{from_name}'s session ended early ({})", in_words(reason))
}
Why::LoggedOut(None) => format!("{from_name} is signed out"),
Why::Unusable(words) | Why::ModelOut(words) => format!("{from_name} is out ({words})"),
Why::KeepsFailing(words) => format!("{from_name}'s sessions keep ending ({words})"),
}
}
fn repeats(reason: &EndReason) -> bool {
matches!(
reason,
EndReason::CliError { .. } | EndReason::Auth { .. } | EndReason::StartFailed { .. }
) && !reason.may_pass()
}
fn stalled(
host: &Host,
job: &Job,
from: &ModelRef,
failed: &[&ModelRef],
why: &Why,
applied: &AppliedFallback,
kept: Option<JobWait>,
) -> EngineError {
let mode = applied.mode;
let memory = lock(&host.shared().job_fallbacks);
let mut logged_out: Vec<String> = Vec::new();
for worker in failed.iter().copied().chain([from]) {
let name = display_name(worker);
if memory.logged_out(worker) && !logged_out.contains(&name) {
logged_out.push(name);
}
}
drop(memory);
let role = job_in_words(job);
let from_name = display_name(from);
let stopped = stopped_in_words(&from_name, why);
let cli = DisplayNames::default().cli(from.agent.as_str());
let builders = match kept.as_ref().map(|wait| &wait.kept_by) {
Some(JobKeeper::BuildersProvider { builders, .. }) => Some(builders),
_ => None,
};
let (detail, line) = if let Some(builders) = builders.filter(|_| mode == FallbackMode::Auto) {
(
format!("{role} waits for {from_name}"),
format!(
"{} waits for {from_name}: {stopped}, and nobody outside the builder's provider \
is left to {} {}.",
argonauts_core::text::sentence_case(&role),
verb_of(job.role),
work_of(builders),
),
)
} else if mode == FallbackMode::Wait {
(
format!("{role} waits for {from_name}"),
format!(
"{} waits for {from_name}: {stopped}, and your fallback settings say to wait \
for it.",
argonauts_core::text::sentence_case(&role)
),
)
} else if applied.same_cli {
(
format!("nobody to fall back to as {role}"),
format!(
"{NOBODY_TO_FALL_BACK_TO} as {role}: {stopped}, and fallback.same_cli keeps \
it on {cli}."
),
)
} else {
let other = if job.isolated {
"no other agent that keeps your plugins out"
} else {
"no other agent"
};
(
format!("nobody to fall back to as {role}"),
format!(
"{NOBODY_TO_FALL_BACK_TO} as {role}: {stopped}, and {other} can take the role."
),
)
};
let resets_at = match why {
Why::Ended(EndReason::UsageLimit { resets_at }) => *resets_at,
Why::Unusable(_) => host
.services()
.plan_usage
.get(&from.agent_id())
.and_then(|reading| reading.resets_at),
_ => None,
};
let (short, said) = failure_words(why, from);
EngineError::Stalled(Box::new(Stall {
detail,
line,
logged_out,
resets_at,
kept: kept.filter(|_| mode == FallbackMode::Auto).map(Box::new),
job: StalledJob {
name: role,
asked: Some(from.clone()),
short,
said,
signed_out: why.signed_out(),
},
tried: {
let mut tried = tried_elsewhere(host, from);
if applied.same_cli && mode != FallbackMode::Wait {
tried.push(format!("fallback.same_cli keeps it on {cli}"));
}
tried
},
in_step: working_in_step(host, job, from),
stop: None,
beside: None,
}))
}
fn failure_words(why: &Why, from: &ModelRef) -> (String, String) {
let cli = DisplayNames::default().cli(from.agent.as_str());
let model = |id: &str| argonauts_core::model_catalog::display_name_from_id(id);
match why {
Why::LoggedOut(_) | Why::Ended(EndReason::Auth { .. }) => {
("signed out".to_owned(), format!("{cli} is signed out"))
}
Why::Unusable(words) | Why::ModelOut(words) => {
((*words).to_owned(), format!("{cli} is out: {words}"))
}
Why::KeepsFailing(words) => {
let words = words.strip_prefix("it ").unwrap_or(words);
(words.to_owned(), format!("{cli} {words}"))
}
Why::Ended(reason) | Why::EndedAgain(reason) => match reason {
EndReason::ModelUnavailable { model: id } => {
let name = model(id);
(
format!("can't run {name}"),
format!("{cli} can't run {name}"),
)
}
EndReason::StartFailed { message } => (
"couldn't start".to_owned(),
format!("{cli} couldn't start: {}", first_line(message)),
),
reason => {
let words = in_words(reason);
(
words.clone(),
format!("{cli}'s session ended early ({words})"),
)
}
},
}
}
pub(crate) fn stalled_task(
host: &Host,
title: &str,
from: &ModelRef,
ended: Option<&EndReason>,
logged_out: bool,
) -> (StalledJob, Vec<String>) {
let why = match ended {
_ if logged_out => Why::LoggedOut(ended.cloned()),
Some(reason) => Why::Ended(reason.clone()),
None => Why::Unusable("it stopped"),
};
let (short, said) = failure_words(&why, from);
let job = StalledJob {
name: format!("\"{}\"", title.trim()),
asked: Some(from.clone()),
short,
said,
signed_out: why.signed_out(),
};
(job, tried_elsewhere(host, from))
}
fn first_line(text: &str) -> &str {
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("")
}
fn tried_elsewhere(host: &Host, from: &ModelRef) -> Vec<String> {
let names = DisplayNames::default();
let memory = lock(&host.shared().job_fallbacks);
let mut tried: Vec<String> = Vec::new();
let mut seen = BTreeSet::new();
for model in host.services().workers.available() {
if model.agent == from.agent || !seen.insert(model.agent.clone()) {
continue;
}
let cli = names.cli(model.agent.as_str());
let why = if let Some(refusal) = start_refusal(host, &model) {
first_line(&refusal)
.split(", and ")
.next()
.unwrap_or_default()
.trim_end_matches('.')
.to_owned()
} else if memory.logged_out(&model) {
format!("{cli} is signed out")
} else {
continue;
};
tried.push(why);
}
tried
}
fn working_in_step(host: &Host, job: &Job, from: &ModelRef) -> Vec<String> {
let names = DisplayNames::default();
let mut working: Vec<String> = Vec::new();
for model in host.read(|state| at_work_in_step(state, job, from)) {
let cli = names.cli(model.agent.as_str());
if !working.contains(&cli) {
working.push(cli);
}
}
working
}
fn at_work_in_step(state: &RunState, job: &Job, from: &ModelRef) -> Vec<ModelRef> {
let (from_seq, until) = round_window(state, job.at.as_ref());
let step = job.at.as_ref().map(|at| &at.step);
let mut sessions: Vec<_> = state
.sessions
.by_key
.values()
.filter(|session| {
session.since >= from_seq
&& until.is_none_or(|until| session.since < until)
&& session.started.workflow_step.as_ref() == step
&& session.started.role == job.role
&& session.started.step == job.step
&& session.task == job.task
&& session.started.worker.agent != from.agent
&& !session
.end
.as_ref()
.is_some_and(|end| end.reason.is_agent_failure())
})
.collect();
sessions.sort_by_key(|session| session.since);
let mut at_work: Vec<ModelRef> = Vec::new();
for session in sessions {
if !at_work.contains(&session.started.worker) {
at_work.push(session.started.worker.clone());
}
}
at_work
}
const RETRIES: u32 = 5;
pub(crate) fn retry_after(settings: &Settings, tries: u32) -> Option<Duration> {
if tries >= RETRIES {
return None;
}
let cooldown = settings.health.cooldown.parse().ok().flatten();
let base = cooldown
.unwrap_or(Duration::from_secs(300))
.max(Duration::from_secs(60));
Some(base * 2u32.pow(tries))
}
async fn wait(host: &Host, wait: Duration) -> EngineResult<()> {
if wait.is_zero() {
return Ok(());
}
let halt = host.shared().halt.clone();
tokio::select! {
() = tokio::time::sleep(wait) => Ok(()),
() = halt.cancelled() => Err(EngineError::Interrupted),
}
}
pub(crate) fn in_words(reason: &EndReason) -> String {
match reason {
EndReason::Auth { .. } => "a sign-in error".to_owned(),
EndReason::RateLimited => "rate limited".to_owned(),
EndReason::UsageLimit { .. } => "its usage limit".to_owned(),
EndReason::Billing { .. } => "out of credits".to_owned(),
EndReason::ModelUnavailable { model } => format!(
"{} isn't available",
argonauts_core::model_catalog::display_name_from_id(model)
),
EndReason::CliError { category, .. } if *category == ErrorCategory::Auth => {
"a sign-in error".to_owned()
}
EndReason::CliError { .. } => "an agent error".to_owned(),
EndReason::Stuck { watchdog, calls } => {
format!("it {}", stuck_words(*watchdog, calls.as_ref()))
}
EndReason::AdapterBroken { .. } => "its output changed".to_owned(),
EndReason::IsolationBroken { plugins, skills } => format!(
"it loaded {}, which a blind role mustn't see",
loaded_words(plugins, skills)
),
EndReason::StartFailed { .. } => "it couldn't start".to_owned(),
_ => "it crashed".to_owned(),
}
}
fn job_as_role(job: &Job) -> String {
let words = job_in_words(job);
match words.strip_prefix("a ") {
Some(role) => role.to_owned(),
None => words,
}
}
fn job_in_words(job: &Job) -> String {
let words = match (job.role, job.step) {
(RoleName::Worker, Some(StepName::ResolveConflict)) => "the conflict's resolver",
(_, Some(StepName::ExperimentReport)) => "an experiment",
(RoleName::Judge, Some(StepName::Settle | StepName::ModelJudge)) => "the judge",
(RoleName::TieBreaker, _) => "the main agent",
(RoleName::Orchestrator, _) => "the orchestrator",
(RoleName::Reader, _) => "a reader",
(RoleName::Comparer, _) => "the comparer",
(RoleName::Draft, _) => "a drafting agent",
(RoleName::Map, _) => "the mapping agent",
(RoleName::Critic, _) => "a critic",
(RoleName::Experiment, _) => "an experiment",
(RoleName::Judge, _) => "a judge",
(RoleName::Worker, _) => "the task's worker",
(RoleName::Consultant, _) => "the consultant",
(RoleName::Reviewer, _) => "a reviewer",
(RoleName::RunReview, _) => "the run review",
(RoleName::Narrator, _) => "the narrator",
(RoleName::Main, _) => "the agent",
};
words.to_owned()
}
pub(crate) fn note(host: &Host, task: Option<&TaskId>, text: String) -> EngineResult<()> {
let level = NoteLevel::Warn;
host.record(task, None, RunEventKind::Note(Note { level, text }))?;
Ok(())
}
#[cfg(test)]
mod tests {
use argonauts_core::events::run::{RunLifecycle, WaitReason};
use argonauts_core::events::session::SessionEvent;
use argonauts_core::events::{RunEvent, RunEventKind};
use super::*;
use crate::state::RunState;
fn health(state: HealthState, since: OffsetDateTime, reason: &str) -> AgentHealth {
AgentHealth {
agent_id: AgentId("codex".to_owned()),
state,
since,
reason: Some(reason.to_owned()),
next_test_at: None,
disabled: None,
}
}
#[test]
fn a_crash_memory_holds_until_a_resume_or_a_passing_test_task() {
let at = OffsetDateTime::UNIX_EPOCH + time::Duration::hours(1);
let failing = Failing {
words: "it crashed".to_owned(),
after: 0,
at,
};
let before = at - time::Duration::minutes(5);
let after = at + time::Duration::minutes(5);
let passed = TEST_TASK_SUCCEEDED;
let quiet = "the errors stopped";
assert!(failing.holds(0, &health(HealthState::Healthy, before, passed)));
assert!(!failing.holds(7, &health(HealthState::Healthy, before, passed)));
assert!(!failing.holds(0, &health(HealthState::Healthy, after, passed)));
assert!(failing.holds(0, &health(HealthState::Healthy, after, quiet)));
assert!(failing.holds(0, &health(HealthState::Testing, after, passed)));
assert!(failing.holds(0, &health(HealthState::Down, after, passed)));
}
#[test]
fn a_cli_that_couldnt_start_is_remembered_for_all_its_models() {
let model = |text: &str| text.parse::<ModelRef>().unwrap();
let failing = |words: &str| Failing {
words: words.to_owned(),
after: 0,
at: OffsetDateTime::UNIX_EPOCH + time::Duration::hours(1),
};
let mut memory = Memory::default();
memory.wont_start.insert(
model("claude-code").agent_id(),
failing("it couldn't start"),
);
memory
.keeps_failing
.insert(model("codex/gpt-5.6"), failing("it crashed twice in a row"));
let words = |text: &str| memory.failing(&model(text)).map(|failing| &*failing.words);
assert_eq!(words("claude-code/opus"), Some("it couldn't start"));
assert_eq!(words("claude-code/haiku"), Some("it couldn't start"));
assert_eq!(words("codex/gpt-5.6"), Some("it crashed twice in a row"));
assert_eq!(words("codex/gpt-5.6-luna"), None);
assert_eq!(words("antigravity-cli"), None);
}
#[test]
fn a_retrys_wait_reads_as_you_wrote_it_until_it_grows() {
use argonauts_core::settings::Span;
use argonauts_core::settings::fallback::Backoff;
let retry = Retry {
tries: 4,
wait: Span::new("5s"),
backoff: Backoff::Double,
};
let said = |n: u32| wait_in_words(&retry, retry.wait_before(n));
assert_eq!(
[said(1), said(2), said(3), said(5)],
["5s", "10s", "20s", "1m 20s"]
);
let written = Retry {
wait: Span::new("1500ms"),
..Retry::default()
};
assert_eq!(wait_in_words(&written, written.wait_before(3)), "1500ms");
let minutes = Retry {
wait: Span::new("30s"),
backoff: Backoff::Double,
..Retry::default()
};
assert_eq!(wait_in_words(&minutes, minutes.wait_before(3)), "2m");
}
#[test]
fn the_retries_wait_longer_each_time_then_stop() {
let settings = Settings::default();
let minutes = |tries| retry_after(&settings, tries).map(|wait| wait.as_secs() / 60);
assert_eq!(
(0..6).map(minutes).collect::<Vec<_>>(),
[Some(5), Some(10), Some(20), Some(40), Some(80), None]
);
}
#[test]
fn a_passing_failure_is_named_for_what_it_was() {
let error = |category| EndReason::CliError {
category,
message: String::new(),
};
assert_eq!(
passing_words(&error(ErrorCategory::Overloaded)),
("is overloaded", "Still overloaded")
);
for outage in [
ErrorCategory::Server,
ErrorCategory::Network,
ErrorCategory::Timeout,
] {
assert_eq!(
passing_words(&error(outage)),
("couldn't be reached", "Still unreachable")
);
}
let rate = ("is rate limited", "Still rate limited");
assert_eq!(passing_words(&EndReason::RateLimited), rate);
assert_eq!(passing_words(&error(ErrorCategory::RateLimit)), rate);
assert_eq!(
passing_words(&error(ErrorCategory::Unknown)),
("gave an error", "Still failing")
);
let crash = EndReason::Crashed {
message: String::new(),
};
assert_eq!(passing_words(&crash), ("crashed", "Crashed again"));
}
#[test]
fn only_a_retry_that_ended_the_same_way_is_still_out() {
let (codex, claude): (ModelRef, ModelRef) = (
"codex/gpt-5.6".parse().expect("a model"),
"claude-code/opus".parse().expect("a model"),
);
let spent = EndReason::UsageLimit { resets_at: None };
assert!(went_again(
&[&codex, &codex],
&codex,
&EndReason::RateLimited
));
assert!(!went_again(&[&codex], &codex, &EndReason::RateLimited));
assert!(!went_again(
&[&claude, &codex],
&codex,
&EndReason::RateLimited
));
assert!(!went_again(&[&codex, &codex], &codex, &spent));
}
#[test]
fn a_finished_session_starts_the_retry_count_over() {
let event = |seq, kind| RunEvent {
seq,
ts: time::OffsetDateTime::UNIX_EPOCH,
run_id: argonauts_core::RunId::new("run-01").expect("a run id"),
task_id: None,
session: Some(argonauts_core::names::SessionKey::numbered(1)),
source: "engine".to_owned(),
kind,
};
let waiting = |seq| {
event(
seq,
RunEventKind::Run(RunLifecycle::Waiting {
reason: WaitReason::NoFallback {
detail: "nobody to fall back to as the main agent".to_owned(),
until: None,
stop: None,
},
waiting_on: Vec::new(),
}),
)
};
let mut state = RunState::default();
state.apply(&waiting(1));
state.apply(&waiting(2));
assert_eq!(state.run.fallback_waits, 2);
let mut end = crate::engine::host::crashed_end("");
end.reason = EndReason::Completed;
state.apply(&event(
3,
RunEventKind::Session(SessionEvent::Ended(Box::new(end))),
));
assert_eq!(state.run.fallback_waits, 0);
}
#[test]
fn the_work_is_named_by_its_builders() {
let model = |text: &str| -> ModelRef { text.parse().expect("a model") };
let (haiku, sonnet) = (model("claude-code/haiku"), model("claude-code/sonnet"));
let short = |model: &ModelRef| roles::short_name(model);
assert_eq!(
work_of(std::slice::from_ref(&haiku)),
format!("{}'s work", short(&haiku))
);
assert_eq!(
work_of(&[haiku.clone(), sonnet.clone(), haiku.clone()]),
format!("the work of {} and {}", short(&haiku), short(&sonnet))
);
assert_eq!(verb_of(RoleName::Judge), "judge");
assert_eq!(verb_of(RoleName::Reviewer), "review");
}
#[test]
fn a_fallbacks_line_names_the_role() {
let job = |role| Job {
at: None,
role,
step: None,
task: None,
independent_of: Vec::new(),
isolated: false,
};
assert_eq!(job_as_role(&job(RoleName::Reviewer)), "reviewer");
assert_eq!(job_as_role(&job(RoleName::TieBreaker)), "the main agent");
assert_eq!(job_as_role(&job(RoleName::Experiment)), "an experiment");
}
#[test]
fn a_workflows_line_says_who_stopped_and_who_it_fell_back_to() {
let (opus, sonnet) = ("Opus 5.5 on Claude Code", "Sonnet 5.5 on Claude Code");
let ended = |category| EndReason::CliError {
category,
message: "it failed".to_owned(),
};
let overloaded = Why::EndedAgain(ended(ErrorCategory::Overloaded));
assert_eq!(
in_the_view(opus, sonnet, Some(&overloaded), true),
"Opus 5.5 on Claude Code is overloaded · fell back to Sonnet 5.5 on Claude Code, as \
same_cli asks"
);
assert_eq!(
in_the_view(opus, "GPT 5.6 on Codex", Some(&overloaded), false),
"Opus 5.5 on Claude Code is overloaded · fell back to GPT 5.6 on Codex"
);
let refused = Why::Ended(ended(ErrorCategory::InvalidRequest));
assert_eq!(
in_the_view(opus, sonnet, Some(&refused), false),
"Opus 5.5 on Claude Code's session ended early (an agent error) · fell back to \
Sonnet 5.5 on Claude Code"
);
assert_eq!(
in_the_view(opus, sonnet, Some(&Why::ModelOut("it's overloaded")), true),
"Opus 5.5 on Claude Code is out (it's overloaded) · fell back to Sonnet 5.5 on \
Claude Code, as same_cli asks"
);
}
#[test]
fn a_tasks_fallback_reads_as_a_steps_does() {
let opus: ModelRef = "claude-code/claude-opus-5-5".parse().expect("a model");
let sonnet: ModelRef = "claude-code/claude-sonnet-5-5".parse().expect("a model");
let codex: ModelRef = "codex/gpt-5.6".parse().expect("a model");
let (from, on_its_cli, elsewhere) = (
display_name(&opus),
display_name(&sonnet),
display_name(&codex),
);
let overloaded = EndReason::CliError {
category: ErrorCategory::Overloaded,
message: "Overloaded".to_owned(),
};
assert_eq!(
task_in_the_view(&opus, &sonnet, Some(&overloaded), true),
format!("{from} is overloaded · fell back to {on_its_cli}, as same_cli asks")
);
let limit = EndReason::UsageLimit { resets_at: None };
assert_eq!(
task_in_the_view(&opus, &codex, Some(&limit), false),
format!("{from}'s session ended early (its usage limit) · fell back to {elsewhere}")
);
for no_failure in [None, Some(&EndReason::Stopped), Some(&EndReason::Completed)] {
assert_eq!(
task_in_the_view(&opus, &codex, no_failure, false),
format!("{from} stopped · fell back to {elsewhere}")
);
}
}
#[test]
fn a_fallback_to_the_same_model_says_so() {
use argonauts_core::events::session::{CallCount, Watchdog};
let qwen: ModelRef = "opencode/ollama/qwen3-coder:30b-64k"
.parse()
.expect("a model");
let name = display_name(&qwen);
let repeated = EndReason::Stuck {
watchdog: Watchdog::Repeat,
calls: Some(CallCount {
name: "execute".into(),
times: 12,
}),
};
assert_eq!(
task_in_the_view(&qwen, &qwen, Some(&repeated), false),
format!(
"{name}'s session ended early (it repeated the same execute call 12 times) · \
fell back to {name} (the same model)"
)
);
let bare: ModelRef = "opencode".parse().expect("a CLI");
assert_eq!(
task_in_the_view(&qwen, &bare, Some(&repeated), false),
format!(
"{name}'s session ended early (it repeated the same execute call 12 times) · \
fell back to OpenCode"
),
"a CLI that names no model may run another"
);
}
#[test]
fn a_revoked_sign_in_is_signed_out() {
let auth = |message: &str| EndReason::Auth {
message: message.to_owned(),
};
assert!(says_signed_out(&auth(
"Failed to authenticate: OAuth token revoked. Please log in again or contact your \
administrator."
)));
assert!(says_signed_out(&auth("Your session expired")));
assert!(!says_signed_out(&auth("401 Unauthorized")));
assert!(!says_signed_out(&EndReason::CliError {
category: argonauts_core::ErrorCategory::Network,
message: "token expired while offline".into(),
}));
}
}