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)
}
}
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,
}
}
#[allow(clippy::type_complexity)]
pub fn fail_stalled_dispatch(
mut agents: Query<(
Entity,
&DispatchStall,
&StageInference,
&mut AgentState,
Option<&mut StageIoBuffer>,
)>,
timeout: Option<Res<StallTimeout>>,
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 = chrono::Utc::now().timestamp();
for (entity, stall, si, mut state, buffer) 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; }
let message = stall.reason.give_up_message(&si.provider_name);
tracing::error!(
provider = %si.provider_name,
reason = stall.reason.label(),
stalled_secs = now.saturating_sub(stall.since),
"failing a run whose provider will never resolve"
);
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>();
}
}
#[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(),
}
}
fn stalled_for(reason: StallReason, age: i64) -> DispatchStall {
let now = chrono::Utc::now().timestamp();
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 run(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_fails_the_run() {
let mut world = World::new();
world.insert_resource(StallTimeout(60));
let e = spawn_stalled(&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::<DispatchStall>(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 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
);
let status = &world.get::<AgentState>(past).unwrap().status;
assert!(
matches!(status, AgentStatus::Error { message } if message.contains("ghost")),
"got: {status:?}"
);
}
#[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);
let status = &world.get::<AgentState>(e).unwrap().status;
assert!(
matches!(status, AgentStatus::Error { message } if message.contains("ghost")),
"got: {status:?}"
);
}
#[test]
fn a_stall_that_stopped_being_refreshed_is_discarded() {
let mut world = World::new();
let now = chrono::Utc::now().timestamp();
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);
let status = &world.get::<AgentState>(e).unwrap().status;
assert!(
matches!(status, AgentStatus::Error { message }
if message.contains("out of service") && message.contains("fallback_order")),
"got: {status:?}"
);
assert!(world.get::<ReadyToInfer>(e).is_none());
}
#[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);
}
}