use crate::agent_maintenance::AgentMaintenanceHealth;
use futures_util::FutureExt;
use stateknot_core::{BoxFuture, CancellationObserver, CancellationSignal};
use stateknot_runtime::TenantSchedulerOutcome;
use std::{panic::AssertUnwindSafe, sync::Arc, time::Duration};
use thiserror::Error;
use tokio::{
task::{JoinHandle, JoinSet},
time::{sleep, timeout},
};
use tokio_util::sync::CancellationToken;
mod binding;
mod health;
mod options;
pub use binding::{AgentWorkerBinding, AgentWorkerExecutionOptions};
pub use health::{AgentWorkerHealth, AgentWorkerStatus};
pub use options::AgentWorkerOptions;
pub trait AgentWorkerReadiness: Send + Sync {
fn check(&self) -> BoxFuture<'_, Result<(), AgentWorkerReadinessError>>;
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Error)]
#[error("Worker host dependencies are unavailable")]
pub struct AgentWorkerReadinessError;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Error)]
pub enum AgentWorkerError {
#[error("Worker options are invalid")]
InvalidOptions,
#[error("Worker dependencies are unavailable")]
Unavailable,
#[error("Worker coordinator is stopped")]
Stopped,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AgentWorkerFailure {
Scheduler,
TickDeadline,
Task,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct AgentWorkerReport {
pub started_ticks: u64,
pub completed_ticks: u64,
pub executed_quanta: u64,
pub run_failures: u64,
pub readiness_failures: u64,
pub forced_ticks: usize,
pub failure: Option<AgentWorkerFailure>,
}
pub struct AgentWorker {
health: AgentWorkerHealth,
stop: CancellationToken,
task: Option<JoinHandle<AgentWorkerReport>>,
}
impl AgentWorker {
pub async fn start(
binding: AgentWorkerBinding,
host: Arc<dyn AgentWorkerReadiness>,
options: AgentWorkerOptions,
) -> Result<Self, AgentWorkerError> {
Self::start_supervised(binding, host, options, None).await
}
pub(crate) async fn start_supervised(
binding: AgentWorkerBinding,
host: Arc<dyn AgentWorkerReadiness>,
options: AgentWorkerOptions,
maintenance: Option<AgentMaintenanceHealth>,
) -> Result<Self, AgentWorkerError> {
if !probe(&binding, host.as_ref(), &options)
.await
.unwrap_or(false)
{
return Err(AgentWorkerError::Unavailable);
}
let health = AgentWorkerHealth::new(options.freshness, binding.activity());
health.probe(true);
let stop = CancellationToken::new();
let guard = RuntimeGuard {
tasks: JoinSet::new(),
health: health.clone(),
stop: stop.clone(),
};
let task = tokio::spawn(run(Arc::new(binding), host, options, guard, maintenance));
Ok(Self {
health,
stop,
task: Some(task),
})
}
pub fn health(&self) -> AgentWorkerHealth {
self.health.clone()
}
pub fn begin_shutdown(&self) {
self.health.draining();
self.stop.cancel();
}
pub async fn wait(&mut self) -> Result<AgentWorkerReport, AgentWorkerError> {
let result = self.task.as_mut().ok_or(AgentWorkerError::Stopped)?.await;
self.task = None;
result.map_err(|_| AgentWorkerError::Stopped)
}
pub async fn shutdown(&mut self) -> Result<AgentWorkerReport, AgentWorkerError> {
self.begin_shutdown();
self.wait().await
}
}
impl Drop for AgentWorker {
fn drop(&mut self) {
self.begin_shutdown();
if let Some(task) = &self.task {
task.abort();
}
}
}
struct Signal(CancellationToken);
impl CancellationObserver for Signal {
fn is_cancelled(&self) -> bool {
self.0.is_cancelled()
}
fn cancelled(&self) -> BoxFuture<'_, ()> {
Box::pin(self.0.cancelled())
}
}
struct RuntimeGuard {
tasks: JoinSet<()>,
health: AgentWorkerHealth,
stop: CancellationToken,
}
impl Drop for RuntimeGuard {
fn drop(&mut self) {
self.stop.cancel();
self.tasks.abort_all();
self.health.stopped();
}
}
async fn probe(
binding: &AgentWorkerBinding,
host: &dyn AgentWorkerReadiness,
options: &AgentWorkerOptions,
) -> Result<bool, ()> {
AssertUnwindSafe(async {
timeout(options.probe_timeout, async {
binding.check().await?;
host.check()
.await
.map_err(|_| AgentWorkerError::Unavailable)
})
.await
.is_ok_and(|result| result.is_ok())
})
.catch_unwind()
.await
.map_err(|_| ())
}
async fn pause(duration: Duration, stop: &CancellationToken) {
tokio::select! { biased; () = stop.cancelled() => {}, () = sleep(duration) => {} }
}
async fn monitor(
binding: Arc<AgentWorkerBinding>,
host: Arc<dyn AgentWorkerReadiness>,
options: AgentWorkerOptions,
health: AgentWorkerHealth,
stop: CancellationToken,
) {
loop {
pause(options.probe_interval, &stop).await;
if stop.is_cancelled() {
return;
}
let result = tokio::select! {
biased;
() = stop.cancelled() => return,
result = probe(&binding, host.as_ref(), &options) => result,
};
if let Ok(ready) = result {
health.probe(ready);
} else {
health.fail(AgentWorkerFailure::Task);
stop.cancel();
return;
}
}
}
async fn slot(
binding: Arc<AgentWorkerBinding>,
options: AgentWorkerOptions,
health: AgentWorkerHealth,
stop: CancellationToken,
maintenance: Option<AgentMaintenanceHealth>,
) {
let signal = CancellationSignal::new(Signal(stop.clone()));
while !stop.is_cancelled() {
if maintenance.as_ref().is_some_and(|health| {
health.status() != crate::agent_maintenance::AgentMaintenanceStatus::Ready
}) {
pause(options.idle_delay, &stop).await;
continue;
}
let Some(active) = health.admit() else {
pause(options.idle_delay, &stop).await;
continue;
};
let tick = binding.tick(signal.clone());
tokio::pin!(tick);
let result = tokio::select! {
result = &mut tick => result,
() = sleep(options.tick_timeout) => {
health.fail(AgentWorkerFailure::TickDeadline);
stop.cancel();
tick.await
}
};
let delay = if let Ok(tick) = result {
health.complete(tick.outcome());
match tick.outcome() {
TenantSchedulerOutcome::Executed { .. } => options.busy_delay,
TenantSchedulerOutcome::ExecutionFailed { .. } => options.failure_delay,
_ => options.idle_delay,
}
} else {
health.fail(AgentWorkerFailure::Scheduler);
stop.cancel();
options.failure_delay
};
drop(active);
pause(delay, &stop).await;
}
}
async fn run(
binding: Arc<AgentWorkerBinding>,
host: Arc<dyn AgentWorkerReadiness>,
options: AgentWorkerOptions,
mut guard: RuntimeGuard,
maintenance: Option<AgentMaintenanceHealth>,
) -> AgentWorkerReport {
guard.tasks.spawn(monitor(
binding.clone(),
host,
options.clone(),
guard.health.clone(),
guard.stop.clone(),
));
for _ in 0..options.slots {
guard.tasks.spawn(slot(
binding.clone(),
options.clone(),
guard.health.clone(),
guard.stop.clone(),
maintenance.clone(),
));
}
tokio::select! {
biased;
() = guard.stop.cancelled() => {},
_ = guard.tasks.join_next() => {
if !guard.stop.is_cancelled() { guard.health.fail(AgentWorkerFailure::Task); }
guard.stop.cancel();
}
}
guard.health.draining();
let drained = timeout(options.drain_timeout, async {
while let Some(result) = guard.tasks.join_next().await {
if result.is_err() {
guard.health.fail(AgentWorkerFailure::Task);
}
}
})
.await
.is_ok();
if !drained {
guard.health.forced();
guard.tasks.abort_all();
while guard.tasks.join_next().await.is_some() {}
}
guard.health.activity.wait_for_idle().await;
guard.health.stopped();
guard.health.report()
}