use crate::daemon::Schedule;
use crate::scheduler::channel::{Channel, ChannelRegistry};
use crate::scheduler::store::JobStore;
use crate::{Subagent, SubagentSpec};
use harness_core::{Model, Task, Tool};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
pub struct Scheduler {
store: Arc<dyn JobStore>,
model: Arc<dyn Model>,
tools: Vec<Arc<dyn Tool>>,
channels: ChannelRegistry,
repo_root: PathBuf,
tick: Duration,
max_iters: u32,
}
impl Scheduler {
pub fn new(store: Arc<dyn JobStore>, model: Arc<dyn Model>) -> Self {
Self {
store,
model,
tools: Vec::new(),
channels: ChannelRegistry::new(),
repo_root: PathBuf::from("."),
tick: Duration::from_secs(60),
max_iters: 20,
}
}
pub fn with_tool(mut self, t: Arc<dyn Tool>) -> Self {
self.tools.push(t);
self
}
pub fn with_channel(mut self, c: Arc<dyn Channel>) -> Self {
self.channels.register(c);
self
}
pub fn with_tick(mut self, d: Duration) -> Self {
self.tick = d;
self
}
pub fn with_max_iters(mut self, n: u32) -> Self {
self.max_iters = n;
self
}
pub fn with_repo_root(mut self, p: impl Into<PathBuf>) -> Self {
self.repo_root = p.into();
self
}
pub fn spawn(self) {
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("scheduler: build tokio rt");
rt.block_on(async move {
loop {
let _ = self.tick_once().await;
tokio::time::sleep(self.tick).await;
}
});
});
}
pub async fn tick_once(&self) -> usize {
let now = chrono::Local::now();
let now_ms = now.timestamp_millis();
let jobs = match self.store.list().await {
Ok(j) => j,
Err(e) => {
tracing::warn!(error = %e, "scheduler: list jobs failed");
return 0;
}
};
let mut fired = 0;
for job in jobs {
if !job.enabled {
continue;
}
let due = job.next_run_ms.map(|t| t <= now_ms).unwrap_or(true);
if !due {
continue;
}
fired += 1;
let mut world = harness_context::default_world(self.repo_root.clone());
let mut spec = SubagentSpec::new(
job.name.clone(),
Task {
description: job.prompt.clone(),
source: None,
deadline: None,
},
)
.with_max_iters(self.max_iters);
for t in &self.tools {
spec = spec.with_tool(t.clone());
}
let sub = Subagent::new(harness_core::DynModel(self.model.clone()), spec);
let output = match sub.run(&mut world).await {
Ok(report) => report.text.unwrap_or_default(),
Err(e) => {
tracing::warn!(job = %job.name, error = %e, "scheduler: job run failed");
String::new()
}
};
let trimmed = output.trim();
if !trimmed.is_empty() && trimmed != "[SILENT]" {
match self.channels.get(&job.channel) {
Some(ch) => {
if let Err(e) = ch.send(&output, &job).await {
tracing::warn!(job = %job.name, error = %e, "scheduler: delivery failed");
}
}
None => {
tracing::warn!(job = %job.name, channel = %job.channel, "scheduler: unknown channel")
}
}
}
let next = Schedule::parse(&job.schedule)
.ok()
.map(|s| s.next_after(now).timestamp_millis());
if let Err(e) = self.store.record_run(&job.id, now_ms, next).await {
tracing::warn!(job = %job.name, error = %e, "scheduler: record_run failed");
}
}
fired
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scheduler::channel::{Channel, ChannelError};
use crate::scheduler::store::{FileJobStore, Job, JobStore};
use async_trait::async_trait;
use harness_core::{Context, ModelError, ModelInfo, ModelOutput};
use std::sync::Mutex as StdMutex;
fn mi() -> ModelInfo {
ModelInfo {
handle: "mock".into(),
provider: "mock".into(),
model: "mock".into(),
context_window: 8192,
input_cost_usd_per_million_tokens: None,
output_cost_usd_per_million_tokens: None,
supports_tool_use: false,
supports_streaming: false,
supports_web_grounding: false,
}
}
struct SayModel {
text: String,
}
#[async_trait]
impl Model for SayModel {
async fn complete(&self, _c: &Context) -> Result<ModelOutput, ModelError> {
Ok(ModelOutput {
text: Some(self.text.clone()),
..Default::default()
})
}
fn info(&self) -> ModelInfo {
mi()
}
}
#[derive(Default)]
struct CapturingChannel {
sent: Arc<StdMutex<Vec<String>>>,
}
#[async_trait]
impl Channel for CapturingChannel {
fn key(&self) -> &str {
"cap"
}
async fn send(&self, output: &str, _job: &Job) -> Result<(), ChannelError> {
self.sent.lock().unwrap().push(output.to_string());
Ok(())
}
}
fn tmp() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::SeqCst);
std::env::temp_dir().join(format!("harness-sched-{}-{n}.json", std::process::id()))
}
#[tokio::test]
async fn due_job_runs_and_delivers_and_advances() {
let p = tmp();
let store: Arc<dyn JobStore> = Arc::new(FileJobStore::open(&p).unwrap());
let job = Job::new("daily-brief", "daily 08:00", "write the brief", "cap", 1)
.with_next_run(Some(0));
store.add(&job).await.unwrap();
let captured = Arc::new(StdMutex::new(Vec::new()));
let cap = CapturingChannel {
sent: captured.clone(),
};
let model: Arc<dyn Model> = Arc::new(SayModel {
text: "the brief".into(),
});
let sched = Scheduler::new(store.clone(), model)
.with_channel(Arc::new(cap))
.with_repo_root(".");
let fired = sched.tick_once().await;
assert_eq!(fired, 1);
assert_eq!(
captured.lock().unwrap().as_slice(),
&["the brief".to_string()]
);
let j = store.get(&job.id).await.unwrap().unwrap();
assert!(j.next_run_ms.unwrap() > chrono::Local::now().timestamp_millis() - 1000);
assert!(j.last_run_ms.is_some());
let _ = std::fs::remove_file(&p);
}
#[tokio::test]
async fn silent_output_is_not_delivered() {
let p = tmp();
let store: Arc<dyn JobStore> = Arc::new(FileJobStore::open(&p).unwrap());
store
.add(&Job::new("j", "daily 08:00", "p", "cap", 1).with_next_run(Some(0)))
.await
.unwrap();
let captured = Arc::new(StdMutex::new(Vec::new()));
let sched = Scheduler::new(
store,
Arc::new(SayModel {
text: "[SILENT]".into(),
}),
)
.with_channel(Arc::new(CapturingChannel {
sent: captured.clone(),
}));
sched.tick_once().await;
assert!(
captured.lock().unwrap().is_empty(),
"[SILENT] suppresses delivery"
);
let _ = std::fs::remove_file(&p);
}
#[tokio::test]
async fn future_job_does_not_fire() {
let p = tmp();
let store: Arc<dyn JobStore> = Arc::new(FileJobStore::open(&p).unwrap());
let future = chrono::Local::now().timestamp_millis() + 10_000_000;
store
.add(&Job::new("j", "daily 08:00", "p", "cap", 1).with_next_run(Some(future)))
.await
.unwrap();
let sched = Scheduler::new(store, Arc::new(SayModel { text: "x".into() }))
.with_channel(Arc::new(CapturingChannel::default()));
assert_eq!(sched.tick_once().await, 0);
let _ = std::fs::remove_file(&p);
}
}