mod grouping;
use std::time::{Duration, Instant};
use crate::ids::AgentId;
pub use grouping::{BatchGroup, GroupingConfig, group_work_items};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CycleStep {
Think,
Reflect,
Evolve,
Mutate,
Survey,
}
impl std::fmt::Display for CycleStep {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Think => write!(f, "think"),
Self::Reflect => write!(f, "reflect"),
Self::Evolve => write!(f, "evolve"),
Self::Mutate => write!(f, "mutate"),
Self::Survey => write!(f, "survey"),
}
}
}
#[derive(Debug)]
pub struct WorkItem<P> {
pub agent_id: AgentId,
pub prompt: P,
pub step: CycleStep,
pub prefix_hash: u64,
pub model: String,
pub queued_at: Instant,
pub token_count: u32,
}
#[derive(Debug)]
pub struct WorkResult<R> {
pub agent_id: AgentId,
pub step: CycleStep,
pub response: std::result::Result<R, BatchError>,
}
#[derive(Debug, thiserror::Error)]
pub enum BatchError {
#[error("API error: {message}")]
Api { message: String },
#[error("item was canceled")]
Canceled,
#[error("item expired")]
Expired,
#[error("transport error: {0}")]
Transport(String),
#[error("{0}")]
Other(#[from] anyhow::Error),
}
pub trait PendingHandle: Send + 'static {}
impl<T: Send + 'static> PendingHandle for T {}
pub enum BatchState<R, H: PendingHandle> {
Pending(H),
Ready(Vec<WorkResult<R>>),
}
#[allow(async_fn_in_trait)]
pub trait BatchBackend<P, R>: Send + Sync {
type Handle: PendingHandle;
async fn submit(
&self,
items: Vec<WorkItem<P>>,
) -> anyhow::Result<Self::Handle>;
async fn poll(
&self,
handle: Self::Handle,
) -> anyhow::Result<BatchState<R, Self::Handle>>;
async fn count_tokens(&self, prompt: &P) -> anyhow::Result<Option<u32>>;
fn backend_name(&self) -> &str;
}
#[derive(Debug, Clone)]
pub struct SchedulerConfig {
pub batch_size: usize,
pub max_wait: Duration,
pub poll_interval: Duration,
pub context_length_bucket: u32,
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
batch_size: 10,
max_wait: Duration::from_secs(120),
poll_interval: Duration::from_secs(5),
context_length_bucket: 4096,
}
}
}
pub struct Scheduler<P> {
config: SchedulerConfig,
queue: Vec<WorkItem<P>>,
}
impl<P> Scheduler<P> {
pub fn new(config: SchedulerConfig) -> Self {
Self {
config,
queue: Vec::new(),
}
}
pub fn enqueue(&mut self, items: impl IntoIterator<Item = WorkItem<P>>) {
self.queue.extend(items);
}
pub fn pending_count(&self) -> usize {
self.queue.len()
}
pub fn is_empty(&self) -> bool {
self.queue.is_empty()
}
pub fn next_batch(&mut self) -> Option<Vec<BatchGroup<P>>> {
if self.queue.is_empty() {
return None;
}
let now = Instant::now();
let batch_size = self.config.batch_size;
let max_wait = self.config.max_wait;
let bucket_size = self.config.context_length_bucket;
let mut starving = Vec::new();
let mut normal = Vec::new();
for item in self.queue.drain(..) {
if now.duration_since(item.queued_at) >= max_wait {
starving.push(item);
} else {
normal.push(item);
}
}
let remaining_capacity = batch_size.saturating_sub(starving.len());
let mut selected = starving;
let mut returned = Vec::new();
if remaining_capacity > 0 && !normal.is_empty() {
normal.sort_by(|a, b| {
a.model
.cmp(&b.model)
.then_with(|| a.prefix_hash.cmp(&b.prefix_hash))
.then_with(|| {
let bucket_a = a.token_count / bucket_size;
let bucket_b = b.token_count / bucket_size;
bucket_a.cmp(&bucket_b)
})
});
let take = remaining_capacity.min(normal.len());
selected.extend(normal.drain(..take));
returned = normal;
}
self.queue = returned;
if selected.is_empty() {
return None;
}
let grouping_config = GroupingConfig {
context_length_bucket: bucket_size,
};
Some(group_work_items(selected, &grouping_config))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_item(
agent_num: u32,
model: &str,
prefix_hash: u64,
token_count: u32,
) -> WorkItem<String> {
WorkItem {
agent_id: AgentId::new(),
prompt: format!("prompt_{agent_num}"),
step: CycleStep::Think,
prefix_hash,
model: model.to_string(),
queued_at: Instant::now(),
token_count,
}
}
fn make_stale_item(
agent_num: u32,
model: &str,
prefix_hash: u64,
) -> WorkItem<String> {
WorkItem {
agent_id: AgentId::new(),
prompt: format!("prompt_{agent_num}"),
step: CycleStep::Think,
prefix_hash,
model: model.to_string(),
queued_at: Instant::now() - Duration::from_secs(300),
token_count: 4096,
}
}
#[test]
fn empty_queue_returns_none() {
let mut sched = Scheduler::<String>::new(SchedulerConfig::default());
assert!(sched.next_batch().is_none());
}
#[test]
fn basic_grouping() {
let mut sched = Scheduler::new(SchedulerConfig {
batch_size: 10,
..Default::default()
});
sched.enqueue(vec![
make_item(1, "claude-opus-4-6", 100, 5000),
make_item(2, "claude-opus-4-6", 100, 5000),
make_item(3, "cogito:14b", 200, 8000),
]);
let groups = sched.next_batch().unwrap();
assert_eq!(groups.len(), 2);
assert!(sched.is_empty());
}
#[test]
fn starvation_prevention() {
let mut sched = Scheduler::new(SchedulerConfig {
batch_size: 2,
max_wait: Duration::from_secs(60),
..Default::default()
});
sched.enqueue(vec![make_stale_item(1, "rare-model", 999)]);
sched.enqueue(vec![
make_item(2, "claude-opus-4-6", 100, 5000),
make_item(3, "claude-opus-4-6", 100, 5000),
]);
let groups = sched.next_batch().unwrap();
let total_items: usize = groups.iter().map(|g| g.items.len()).sum();
assert_eq!(total_items, 2);
let has_rare = groups.iter().any(|g| g.model == "rare-model");
assert!(has_rare, "stale item should be force-scheduled");
assert_eq!(sched.pending_count(), 1);
}
#[test]
fn batch_size_limit() {
let mut sched = Scheduler::new(SchedulerConfig {
batch_size: 2,
..Default::default()
});
sched.enqueue(vec![
make_item(1, "claude-opus-4-6", 100, 5000),
make_item(2, "claude-opus-4-6", 100, 5000),
make_item(3, "claude-opus-4-6", 100, 5000),
]);
let groups = sched.next_batch().unwrap();
let total: usize = groups.iter().map(|g| g.items.len()).sum();
assert_eq!(total, 2);
assert_eq!(sched.pending_count(), 1);
}
}