use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StallReason {
ProviderMissing,
PoolFull,
ProviderCircuitOpen,
}
impl StallReason {
pub(crate) fn label(self) -> &'static str {
match self {
StallReason::ProviderMissing => "provider-missing",
StallReason::PoolFull => "pool-full",
StallReason::ProviderCircuitOpen => "provider-circuit-open",
}
}
fn needs_a_person(self) -> bool {
match self {
StallReason::ProviderMissing | StallReason::ProviderCircuitOpen => true,
StallReason::PoolFull => false,
}
}
fn give_up_message(self, provider: &str) -> String {
match self {
StallReason::ProviderCircuitOpen => format!(
"every provider this stage can use is out of service (last was \
'{provider}'), so this run has nowhere to go; check the account's \
credits and API key, or add another provider to \
`[providers] fallback_order`"
),
_ => format!(
"provider '{provider}' is not configured, so this run has no way to \
go on; add it to config.toml (or run `lev setup`) and restart the daemon"
),
}
}
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct DispatchStall {
pub since: i64,
pub last_seen: i64,
pub reason: StallReason,
}
pub(crate) const STALL_FRESHNESS_SECS: i64 = 120;
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq)]
pub struct StallTimeout(pub u64);
impl Default for StallTimeout {
fn default() -> Self {
Self(DEFAULT_STALL_TIMEOUT_SECS)
}
}
#[derive(Resource, Debug, Clone, Copy)]
pub struct StallClock(
pub fn() -> i64,
);
fn now_secs() -> i64 {
chrono::Utc::now().timestamp()
}
pub const DEFAULT_STALL_TIMEOUT_SECS: u64 = 60;
pub(crate) fn note_stall(
existing: Option<&DispatchStall>,
reason: StallReason,
now: i64,
) -> DispatchStall {
let since = match existing {
Some(prev)
if prev.reason == reason
&& now.saturating_sub(prev.last_seen) <= STALL_FRESHNESS_SECS =>
{
prev.since
}
_ => now,
};
DispatchStall {
since,
last_seen: now,
reason,
}
}
type StalledDispatchQuery = (
Entity,
&'static DispatchStall,
&'static StageInference,
&'static mut AgentState,
Option<&'static mut StageIoBuffer>,
Option<&'static crate::persistence::RunMetadata>,
);
#[derive(Component, Debug, Clone, PartialEq, Eq)]
pub struct PausedForSetup {
pub blocker: leviath_core::run_meta::SetupBlocker,
pub remedy: String,
}
pub fn fail_stalled_dispatch(
mut agents: Query<StalledDispatchQuery>,
timeout: Option<Res<StallTimeout>>,
clock: Option<Res<StallClock>>,
circuits: Option<Res<super::circuit::ProviderCircuits>>,
mut commands: Commands,
) {
crate::tick_scope::clear();
let limit = timeout.map(|t| t.0).unwrap_or(DEFAULT_STALL_TIMEOUT_SECS);
if limit == 0 {
return; }
let now = clock.map_or_else(now_secs, |c| (c.0)());
for (entity, stall, si, mut state, buffer, md) in agents.iter_mut() {
crate::tick_scope::enter(entity);
if state.status != AgentStatus::Active || !stall.reason.needs_a_person() {
continue;
}
if now.saturating_sub(stall.last_seen) > STALL_FRESHNESS_SECS {
tracing::debug!(
reason = stall.reason.label(),
"discarding a dispatch stall that stopped being refreshed"
);
commands.entity(entity).remove::<DispatchStall>();
continue;
}
if now.saturating_sub(stall.since) < limit as i64 {
continue; }
use leviath_core::run_meta::SetupBlocker;
let last_reason = circuits
.as_ref()
.and_then(|c| c.last_reason(&si.provider_name));
let blocker = match stall.reason {
StallReason::ProviderCircuitOpen => match last_reason {
Some(leviath_providers::UnavailableReason::CreditsExhausted) => {
SetupBlocker::CreditsExhausted
}
Some(leviath_providers::UnavailableReason::AuthFailed) => SetupBlocker::AuthFailed,
Some(leviath_providers::UnavailableReason::Forbidden) => SetupBlocker::Forbidden,
_ => SetupBlocker::ProvidersUnavailable,
},
_ => SetupBlocker::ProviderMissing,
};
let message = match blocker {
SetupBlocker::CreditsExhausted => format!(
"out of credits on '{}': top up the account, then `lev resume` \
this run",
si.provider_name
),
SetupBlocker::AuthFailed => format!(
"'{}' rejected the API key: replace it with `lev setup`, then \
`lev resume` this run",
si.provider_name
),
SetupBlocker::Forbidden => format!(
"'{}' will not serve this model to that key: check the account's \
plan and model permissions, then `lev resume` this run",
si.provider_name
),
_ => stall.reason.give_up_message(&si.provider_name),
};
let unattended = md.is_some_and(|m| m.unattended);
if unattended {
tracing::error!(
provider = %si.provider_name,
reason = stall.reason.label(),
stalled_secs = now.saturating_sub(stall.since),
"failing an unattended run: nobody is there to fix it"
);
if let Some(mut buffer) = buffer {
buffer.logs.push((0, format!("[stalled] {message}")));
}
state.status = AgentStatus::Error { message };
commands
.entity(entity)
.remove::<ReadyToInfer>()
.remove::<DispatchStall>();
continue;
}
tracing::warn!(
provider = %si.provider_name,
reason = stall.reason.label(),
stalled_secs = now.saturating_sub(stall.since),
"pausing a run until the machine is fixed"
);
if let Some(mut buffer) = buffer {
buffer.logs.push((0, format!("[paused] {message}")));
}
state.status = AgentStatus::Paused;
commands
.entity(entity)
.insert(PausedForSetup {
blocker,
remedy: message,
})
.remove::<DispatchStall>();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn agent_state() -> AgentState {
AgentState {
agent_id: "a".to_string(),
current_stage: "s".to_string(),
iteration: 0,
status: AgentStatus::Active,
spawned_children_ids: vec![],
pending_wait: None,
accepts_messages: true,
}
}
fn stage_inference() -> StageInference {
StageInference {
provider_name: "ghost".to_string(),
model: "m".to_string(),
tools: vec![],
tool_filter: None,
fallbacks: Vec::new(),
output: None,
}
}
const NOW: i64 = 1_700_000_000;
fn stalled_for(reason: StallReason, age: i64) -> DispatchStall {
DispatchStall {
since: NOW - age,
last_seen: NOW,
reason,
}
}
fn spawn_stalled(world: &mut World, reason: StallReason, age: i64) -> Entity {
world
.spawn((
agent_state(),
stage_inference(),
stalled_for(reason, age),
StageIoBuffer::default(),
ReadyToInfer,
))
.id()
}
fn spawn_stalled_unattended(world: &mut World, reason: StallReason, age: i64) -> Entity {
let e = spawn_stalled(world, reason, age);
world.entity_mut(e).insert(run_metadata(true));
e
}
fn run_metadata(unattended: bool) -> crate::persistence::RunMetadata {
crate::persistence::RunMetadata {
run_id: "r".to_string(),
agent_name: "a".to_string(),
agent_path: String::new(),
task: String::new(),
model: None,
workdir: String::new(),
num_stages: 1,
started_at: 0,
parent_run_id: None,
metadata: std::collections::HashMap::new(),
callback_url: None,
callback_secret: None,
title: None,
unattended,
read_paths: None,
output_request: None,
}
}
fn assert_paused_for_setup(world: &World, e: Entity, remedy: &str) {
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Paused,
"a fixable problem parks the run rather than ending it"
);
let marker = world
.get::<PausedForSetup>(e)
.expect("a parked run says what to do");
assert!(marker.remedy.contains(remedy), "{}", marker.remedy);
assert!(world.get::<ReadyToInfer>(e).is_some());
assert!(world.get::<DispatchStall>(e).is_none());
}
fn run(world: &mut World) {
world.insert_resource(StallClock(|| NOW));
run_on_the_wall_clock(world);
}
fn run_on_the_wall_clock(world: &mut World) {
let mut schedule = Schedule::default();
schedule.add_systems(fail_stalled_dispatch);
schedule.run(world);
}
#[test]
fn a_provider_that_will_never_resolve_parks_the_run_for_a_person() {
let mut world = World::new();
world.insert_resource(StallTimeout(60));
let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 61);
run(&mut world);
assert_paused_for_setup(&world, e, "not configured");
let logs = &world.get::<StageIoBuffer>(e).unwrap().logs;
assert!(
logs.iter().any(|(_, line)| line.starts_with("[paused]")),
"expected a [paused] log line, got: {logs:?}"
);
}
#[test]
fn an_unattended_run_still_fails_because_nobody_will_fix_it() {
let mut world = World::new();
world.insert_resource(StallTimeout(60));
let e = spawn_stalled_unattended(&mut world, StallReason::ProviderMissing, 61);
run(&mut world);
let status = &world.get::<AgentState>(e).unwrap().status;
assert!(
matches!(status, AgentStatus::Error { message }
if message.contains("ghost") && message.contains("not configured")),
"got: {status:?}"
);
assert!(world.get::<ReadyToInfer>(e).is_none());
assert!(world.get::<PausedForSetup>(e).is_none());
let logs = &world.get::<StageIoBuffer>(e).unwrap().logs;
assert!(
logs.iter().any(|(_, line)| line.starts_with("[stalled]")),
"expected a [stalled] log line, got: {logs:?}"
);
}
#[test]
fn a_stall_inside_the_grace_period_is_left_alone() {
let mut world = World::new();
world.insert_resource(StallTimeout(60));
let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 59);
run(&mut world);
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Active
);
assert!(world.get::<ReadyToInfer>(e).is_some());
}
#[test]
fn the_grace_period_ends_the_second_it_is_reached() {
let mut world = World::new();
world.insert_resource(StallTimeout(60));
let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 60);
run(&mut world);
assert_paused_for_setup(&world, e, "ghost");
}
#[test]
fn nothing_pinning_the_clock_means_the_wall_clock() {
let mut world = World::new();
world.insert_resource(StallTimeout(60));
let now = chrono::Utc::now().timestamp();
let e = world
.spawn((
agent_state(),
stage_inference(),
DispatchStall {
since: now - 10_000,
last_seen: now,
reason: StallReason::ProviderMissing,
},
ReadyToInfer,
))
.id();
run_on_the_wall_clock(&mut world);
assert_paused_for_setup(&world, e, "ghost");
}
#[test]
fn a_full_pool_is_backpressure_and_is_never_failed() {
let mut world = World::new();
world.insert_resource(StallTimeout(60));
let e = spawn_stalled(&mut world, StallReason::PoolFull, 10_000);
run(&mut world);
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Active
);
assert!(world.get::<ReadyToInfer>(e).is_some());
assert!(world.get::<DispatchStall>(e).is_some());
}
#[test]
fn a_zero_timeout_disables_the_watchdog() {
let mut world = World::new();
world.insert_resource(StallTimeout(0));
let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 10_000);
run(&mut world);
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Active
);
}
#[test]
fn a_world_without_the_resource_uses_the_default_timeout() {
let mut world = World::new();
let inside = spawn_stalled(
&mut world,
StallReason::ProviderMissing,
DEFAULT_STALL_TIMEOUT_SECS as i64 - 1,
);
let past = spawn_stalled(
&mut world,
StallReason::ProviderMissing,
DEFAULT_STALL_TIMEOUT_SECS as i64 + 1,
);
run(&mut world);
assert_eq!(
world.get::<AgentState>(inside).unwrap().status,
AgentStatus::Active
);
assert_paused_for_setup(&world, past, "ghost");
}
#[test]
fn a_non_active_agent_is_left_to_its_own_status() {
let mut world = World::new();
let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 10_000);
world.get_mut::<AgentState>(e).unwrap().status = AgentStatus::Paused;
run(&mut world);
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Paused
);
}
#[test]
fn an_agent_without_a_stage_log_still_fails() {
let mut world = World::new();
let e = world
.spawn((
agent_state(),
stage_inference(),
stalled_for(StallReason::ProviderMissing, 10_000),
ReadyToInfer,
))
.id();
run(&mut world);
assert_paused_for_setup(&world, e, "ghost");
}
#[test]
fn a_stall_that_stopped_being_refreshed_is_discarded() {
let mut world = World::new();
let e = world
.spawn((
agent_state(),
stage_inference(),
DispatchStall {
since: NOW - 10_000,
last_seen: NOW - STALL_FRESHNESS_SECS - 1,
reason: StallReason::ProviderMissing,
},
ReadyToInfer,
))
.id();
run(&mut world);
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Active
);
assert!(
world.get::<DispatchStall>(e).is_none(),
"the spent record is cleared rather than left to mislead"
);
}
#[test]
fn note_stall_continues_a_live_stall_and_restarts_otherwise() {
let first = note_stall(None, StallReason::PoolFull, 100);
assert_eq!((first.since, first.last_seen), (100, 100));
let still = note_stall(Some(&first), StallReason::PoolFull, 120);
assert_eq!(still.since, 100, "an ongoing stall keeps its clock");
assert_eq!(still.last_seen, 120, "but records that it is still live");
let changed = note_stall(Some(&first), StallReason::ProviderMissing, 120);
assert_eq!(changed.since, 120);
assert_eq!(changed.reason, StallReason::ProviderMissing);
let resumed = note_stall(
Some(&first),
StallReason::PoolFull,
100 + STALL_FRESHNESS_SECS + 1,
);
assert_eq!(resumed.since, 100 + STALL_FRESHNESS_SECS + 1);
}
#[test]
fn stall_reasons_have_labels() {
assert_eq!(StallReason::ProviderMissing.label(), "provider-missing");
assert_eq!(StallReason::PoolFull.label(), "pool-full");
assert_eq!(
StallReason::ProviderCircuitOpen.label(),
"provider-circuit-open"
);
}
#[test]
fn only_the_reasons_a_person_must_fix_are_failed() {
assert!(StallReason::ProviderMissing.needs_a_person());
assert!(StallReason::ProviderCircuitOpen.needs_a_person());
assert!(!StallReason::PoolFull.needs_a_person());
}
#[test]
fn a_run_with_every_provider_out_of_service_is_failed_not_left_running() {
let mut world = World::new();
world.insert_resource(StallTimeout(60));
let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 61);
run(&mut world);
assert_paused_for_setup(&world, e, "out of service");
}
#[test]
fn a_run_out_of_credits_is_paused_for_a_resume_not_failed() {
let mut world = World::new();
world.insert_resource(StallTimeout(60));
let mut circuits = super::super::circuit::ProviderCircuits::default();
let policy = super::super::circuit::CircuitPolicy::default();
for i in 0..3 {
circuits.record_failure(
"ghost",
leviath_providers::UnavailableReason::CreditsExhausted,
NOW - 3 + i,
&policy,
);
}
world.insert_resource(circuits);
let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 61);
run(&mut world);
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Paused
);
assert!(
world.get::<ReadyToInfer>(e).is_some(),
"the retry is staged"
);
assert!(world.get::<DispatchStall>(e).is_none());
let logs = &world.get::<StageIoBuffer>(e).unwrap().logs;
let line = logs
.iter()
.map(|(_, l)| l.as_str())
.find(|l| l.starts_with("[paused]"))
.expect("the pause is written to the stage log");
assert!(line.contains("out of credits"), "{line}");
assert!(line.contains("lev resume"), "{line}");
}
#[test]
fn the_credits_pause_copes_without_a_stage_log_buffer() {
let mut world = World::new();
world.insert_resource(StallTimeout(60));
let mut circuits = super::super::circuit::ProviderCircuits::default();
let policy = super::super::circuit::CircuitPolicy::default();
for i in 0..3 {
circuits.record_failure(
"ghost",
leviath_providers::UnavailableReason::CreditsExhausted,
NOW - 3 + i,
&policy,
);
}
world.insert_resource(circuits);
let e = world
.spawn((
agent_state(),
stage_inference(),
stalled_for(StallReason::ProviderCircuitOpen, 61),
ReadyToInfer,
))
.id();
run(&mut world);
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Paused
);
}
#[test]
fn each_kind_of_provider_failure_names_its_own_remedy() {
use leviath_core::run_meta::SetupBlocker;
let cases = [
(
leviath_providers::UnavailableReason::CreditsExhausted,
SetupBlocker::CreditsExhausted,
"top up",
),
(
leviath_providers::UnavailableReason::AuthFailed,
SetupBlocker::AuthFailed,
"rejected the API key",
),
(
leviath_providers::UnavailableReason::Forbidden,
SetupBlocker::Forbidden,
"will not serve this model",
),
(
leviath_providers::UnavailableReason::Unreachable,
SetupBlocker::ProvidersUnavailable,
"out of service",
),
];
for (reason, expected, remedy) in cases {
let mut world = World::new();
world.insert_resource(StallTimeout(60));
let mut circuits = super::super::circuit::ProviderCircuits::default();
let policy = super::super::circuit::CircuitPolicy::default();
circuits.record_failure("ghost", reason, NOW - 1, &policy);
world.insert_resource(circuits);
let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 61);
run(&mut world);
assert_paused_for_setup(&world, e, remedy);
assert_eq!(
world.get::<PausedForSetup>(e).unwrap().blocker,
expected,
"{reason:?}"
);
}
}
#[test]
fn an_unattended_failure_copes_without_a_stage_log_buffer() {
let mut world = World::new();
world.insert_resource(StallTimeout(60));
let e = world
.spawn((
agent_state(),
stage_inference(),
stalled_for(StallReason::ProviderMissing, 61),
run_metadata(true),
ReadyToInfer,
))
.id();
run(&mut world);
let status = format!("{:?}", world.get::<AgentState>(e).unwrap().status);
assert!(status.contains("Error"), "{status}");
}
#[test]
fn a_missing_provider_is_its_own_kind_of_blocker() {
use leviath_core::run_meta::SetupBlocker;
let mut world = World::new();
world.insert_resource(StallTimeout(60));
let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 61);
run(&mut world);
assert_eq!(
world.get::<PausedForSetup>(e).unwrap().blocker,
SetupBlocker::ProviderMissing
);
}
#[test]
fn an_open_circuit_inside_the_grace_period_gets_its_chance_to_recover() {
let mut world = World::new();
world.insert_resource(StallTimeout(60));
let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 59);
run(&mut world);
assert_eq!(
world.get::<AgentState>(e).unwrap().status,
AgentStatus::Active
);
}
#[test]
fn the_give_up_message_names_the_provider() {
let missing = StallReason::ProviderMissing.give_up_message("ghost");
assert!(missing.contains("ghost") && missing.contains("not configured"));
let open = StallReason::ProviderCircuitOpen.give_up_message("openrouter");
assert!(open.contains("openrouter") && open.contains("out of service"));
assert!(StallReason::PoolFull.give_up_message("x").contains("x"));
}
#[test]
fn the_default_timeout_is_the_documented_grace_period() {
assert_eq!(StallTimeout::default().0, DEFAULT_STALL_TIMEOUT_SECS);
}
}