Files
tkmind_go/crates/goose/src/scheduler.rs
T
Douwe Osinga 7c1f5dcb2f Compaction overhaul (#5186)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: David Katz <dkatz@squareup.com>
Co-authored-by: Alex Hancock <alexhancock@block.xyz>
2025-10-15 18:39:12 -04:00

1499 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tokio_cron_scheduler::{job::JobId, Job, JobScheduler as TokioJobScheduler};
use crate::agents::AgentEvent;
use crate::agents::{Agent, SessionConfig};
use crate::config::paths::Paths;
use crate::config::Config;
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::providers::base::Provider as GooseProvider; // Alias to avoid conflict in test section
use crate::providers::create;
use crate::recipe::Recipe;
use crate::scheduler_trait::SchedulerTrait;
use crate::session::{Session, SessionManager};
// Track running tasks with their abort handles
type RunningTasksMap = HashMap<String, tokio::task::AbortHandle>;
type JobsMap = HashMap<String, (JobId, ScheduledJob)>;
/// Normalize a cron string so that:
/// 1. It is always in **quartz 7-field format** expected by Temporal
/// (seconds minutes hours dom month dow year).
/// 2. Five-field → prepend seconds `0` and append year `*`.
/// Six-field → append year `*`.
/// 3. Everything else returned unchanged (with a warning).
pub fn normalize_cron_expression(src: &str) -> String {
let mut parts: Vec<&str> = src.split_whitespace().collect();
match parts.len() {
5 => {
// min hour dom mon dow → 0 min hour dom mon dow *
parts.insert(0, "0");
parts.push("*");
}
6 => {
// sec min hour dom mon dow → sec min hour dom mon dow *
parts.push("*");
}
7 => {
// already quartz do nothing
}
_ => {
tracing::warn!(
"Unrecognised cron expression '{}': expected 5, 6 or 7 fields (got {}). Leaving unchanged.",
src,
parts.len()
);
return src.to_string();
}
}
parts.join(" ")
}
pub fn get_default_scheduler_storage_path() -> Result<PathBuf, io::Error> {
let data_dir = Paths::data_dir();
fs::create_dir_all(&data_dir)?;
Ok(data_dir.join("schedules.json"))
}
pub fn get_default_scheduled_recipes_dir() -> Result<PathBuf, SchedulerError> {
let data_dir = Paths::data_dir();
let recipes_dir = data_dir.join("scheduled_recipes");
fs::create_dir_all(&recipes_dir).map_err(SchedulerError::StorageError)?;
tracing::debug!(
"Created scheduled recipes directory at: {}",
recipes_dir.display()
);
Ok(recipes_dir)
}
#[derive(Debug)]
pub enum SchedulerError {
JobIdExists(String),
JobNotFound(String),
StorageError(io::Error),
RecipeLoadError(String),
AgentSetupError(String),
PersistError(String),
CronParseError(String),
SchedulerInternalError(String),
AnyhowError(anyhow::Error),
}
impl std::fmt::Display for SchedulerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SchedulerError::JobIdExists(id) => write!(f, "Job ID '{}' already exists.", id),
SchedulerError::JobNotFound(id) => write!(f, "Job ID '{}' not found.", id),
SchedulerError::StorageError(e) => write!(f, "Storage error: {}", e),
SchedulerError::RecipeLoadError(e) => write!(f, "Recipe load error: {}", e),
SchedulerError::AgentSetupError(e) => write!(f, "Agent setup error: {}", e),
SchedulerError::PersistError(e) => write!(f, "Failed to persist schedules: {}", e),
SchedulerError::CronParseError(e) => write!(f, "Invalid cron string: {}", e),
SchedulerError::SchedulerInternalError(e) => {
write!(f, "Scheduler internal error: {}", e)
}
SchedulerError::AnyhowError(e) => write!(f, "Scheduler operation failed: {}", e),
}
}
}
impl std::error::Error for SchedulerError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SchedulerError::StorageError(e) => Some(e),
SchedulerError::AnyhowError(e) => Some(e.as_ref()),
_ => None,
}
}
}
impl From<io::Error> for SchedulerError {
fn from(err: io::Error) -> Self {
SchedulerError::StorageError(err)
}
}
impl From<serde_json::Error> for SchedulerError {
fn from(err: serde_json::Error) -> Self {
SchedulerError::PersistError(err.to_string())
}
}
impl From<anyhow::Error> for SchedulerError {
fn from(err: anyhow::Error) -> Self {
SchedulerError::AnyhowError(err)
}
}
#[derive(Clone, Serialize, Deserialize, Debug, utoipa::ToSchema)]
pub struct ScheduledJob {
pub id: String,
pub source: String,
pub cron: String,
pub last_run: Option<DateTime<Utc>>,
#[serde(default)]
pub currently_running: bool,
#[serde(default)]
pub paused: bool,
#[serde(default)]
pub current_session_id: Option<String>,
#[serde(default)]
pub process_start_time: Option<DateTime<Utc>>,
#[serde(default)]
pub execution_mode: Option<String>, // "foreground" or "background"
}
async fn persist_jobs_from_arc(
storage_path: &Path,
jobs_arc: &Arc<Mutex<JobsMap>>,
) -> Result<(), SchedulerError> {
let jobs_guard = jobs_arc.lock().await;
let list: Vec<ScheduledJob> = jobs_guard.values().map(|(_, j)| j.clone()).collect();
if let Some(parent) = storage_path.parent() {
fs::create_dir_all(parent).map_err(SchedulerError::StorageError)?;
}
let data = serde_json::to_string_pretty(&list).map_err(SchedulerError::from)?;
fs::write(storage_path, data).map_err(SchedulerError::StorageError)?;
Ok(())
}
pub struct Scheduler {
internal_scheduler: TokioJobScheduler,
jobs: Arc<Mutex<JobsMap>>,
storage_path: PathBuf,
running_tasks: Arc<Mutex<RunningTasksMap>>,
}
impl Scheduler {
pub async fn new(storage_path: PathBuf) -> Result<Arc<Self>, SchedulerError> {
let internal_scheduler = TokioJobScheduler::new()
.await
.map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?;
let jobs = Arc::new(Mutex::new(HashMap::new()));
let running_tasks = Arc::new(Mutex::new(HashMap::new()));
let arc_self = Arc::new(Self {
internal_scheduler,
jobs,
storage_path,
running_tasks,
});
arc_self.load_jobs_from_storage().await?;
arc_self
.internal_scheduler
.start()
.await
.map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?;
Ok(arc_self)
}
pub async fn add_scheduled_job(
&self,
original_job_spec: ScheduledJob,
) -> Result<(), SchedulerError> {
let mut jobs_guard = self.jobs.lock().await;
if jobs_guard.contains_key(&original_job_spec.id) {
return Err(SchedulerError::JobIdExists(original_job_spec.id.clone()));
}
let original_recipe_path = Path::new(&original_job_spec.source);
if !original_recipe_path.exists() {
return Err(SchedulerError::RecipeLoadError(format!(
"Original recipe file not found: {}",
original_job_spec.source
)));
}
if !original_recipe_path.is_file() {
return Err(SchedulerError::RecipeLoadError(format!(
"Original recipe source is not a file: {}",
original_job_spec.source
)));
}
let scheduled_recipes_dir = get_default_scheduled_recipes_dir()?;
let original_extension = original_recipe_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("yaml");
let destination_filename = format!("{}.{}", original_job_spec.id, original_extension);
let destination_recipe_path = scheduled_recipes_dir.join(destination_filename);
tracing::info!(
"Copying recipe from {} to {}",
original_recipe_path.display(),
destination_recipe_path.display()
);
fs::copy(original_recipe_path, &destination_recipe_path).map_err(|e| {
SchedulerError::StorageError(io::Error::new(
e.kind(),
format!(
"Failed to copy recipe from {} to {}: {}",
original_job_spec.source,
destination_recipe_path.display(),
e
),
))
})?;
let mut stored_job = original_job_spec.clone();
stored_job.source = destination_recipe_path.to_string_lossy().into_owned();
stored_job.current_session_id = None;
stored_job.process_start_time = None;
tracing::info!("Updated job source path to: {}", stored_job.source);
let job_for_task = stored_job.clone();
let jobs_arc_for_task = self.jobs.clone();
let storage_path_for_task = self.storage_path.clone();
let running_tasks_for_task = self.running_tasks.clone();
tracing::info!("Attempting to parse cron expression: '{}'", stored_job.cron);
let normalized_cron = normalize_cron_expression(&stored_job.cron);
// Convert from 7-field (Temporal format) to 6-field (tokio-cron-scheduler format)
let tokio_cron = {
let parts: Vec<&str> = normalized_cron.split_whitespace().collect();
if parts.len() == 7 {
parts[..6].join(" ")
} else {
normalized_cron.clone()
}
};
if tokio_cron != stored_job.cron {
tracing::info!(
"Converted cron expression from '{}' to '{}' for tokio-cron-scheduler",
stored_job.cron,
tokio_cron
);
}
let cron_task = Job::new_async(&tokio_cron, move |_uuid, _l| {
let task_job_id = job_for_task.id.clone();
let current_jobs_arc = jobs_arc_for_task.clone();
let local_storage_path = storage_path_for_task.clone();
let job_to_execute = job_for_task.clone(); // Clone for run_scheduled_job_internal
let running_tasks_arc = running_tasks_for_task.clone();
Box::pin(async move {
// Check if the job is paused before executing
let should_execute = {
let jobs_map_guard = current_jobs_arc.lock().await;
if let Some((_, current_job_in_map)) = jobs_map_guard.get(&task_job_id) {
!current_job_in_map.paused
} else {
false
}
};
if !should_execute {
tracing::info!("Skipping execution of paused job '{}'", &task_job_id);
return;
}
let current_time = Utc::now();
let mut needs_persist = false;
{
let mut jobs_map_guard = current_jobs_arc.lock().await;
if let Some((_, current_job_in_map)) = jobs_map_guard.get_mut(&task_job_id) {
current_job_in_map.last_run = Some(current_time);
current_job_in_map.currently_running = true;
current_job_in_map.process_start_time = Some(current_time);
needs_persist = true;
}
}
if needs_persist {
if let Err(e) =
persist_jobs_from_arc(&local_storage_path, &current_jobs_arc).await
{
tracing::error!(
"Failed to persist last_run update for job {}: {}",
&task_job_id,
e
);
}
}
// Spawn the job execution as an abortable task
let job_task = tokio::spawn(run_scheduled_job_internal(
job_to_execute.clone(),
None,
Some(current_jobs_arc.clone()),
Some(task_job_id.clone()),
));
// Store the abort handle at the scheduler level
{
let mut running_tasks_guard = running_tasks_arc.lock().await;
running_tasks_guard.insert(task_job_id.clone(), job_task.abort_handle());
}
// Wait for the job to complete or be aborted
let result = job_task.await;
// Remove the abort handle
{
let mut running_tasks_guard = running_tasks_arc.lock().await;
running_tasks_guard.remove(&task_job_id);
}
// Update the job status after execution
{
let mut jobs_map_guard = current_jobs_arc.lock().await;
if let Some((_, current_job_in_map)) = jobs_map_guard.get_mut(&task_job_id) {
current_job_in_map.currently_running = false;
current_job_in_map.current_session_id = None;
current_job_in_map.process_start_time = None;
needs_persist = true;
}
}
if needs_persist {
if let Err(e) =
persist_jobs_from_arc(&local_storage_path, &current_jobs_arc).await
{
tracing::error!(
"Failed to persist running status update for job {}: {}",
&task_job_id,
e
);
}
}
match result {
Ok(Ok(_session_id)) => {
tracing::info!("Scheduled job '{}' completed successfully", &task_job_id);
}
Ok(Err(e)) => {
tracing::error!(
"Scheduled job '{}' execution failed: {}",
&e.job_id,
e.error
);
}
Err(join_error) if join_error.is_cancelled() => {
tracing::info!("Scheduled job '{}' was cancelled/killed", &task_job_id);
}
Err(join_error) => {
tracing::error!(
"Scheduled job '{}' task failed: {}",
&task_job_id,
join_error
);
}
}
})
})
.map_err(|e| SchedulerError::CronParseError(e.to_string()))?;
let job_uuid = self
.internal_scheduler
.add(cron_task)
.await
.map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?;
jobs_guard.insert(stored_job.id.clone(), (job_uuid, stored_job));
// Pass the jobs_guard by reference for the initial persist after adding a job
self.persist_jobs_to_storage_with_guard(&jobs_guard).await?;
Ok(())
}
async fn load_jobs_from_storage(self: &Arc<Self>) -> Result<(), SchedulerError> {
if !self.storage_path.exists() {
return Ok(());
}
let data = fs::read_to_string(&self.storage_path)?;
if data.trim().is_empty() {
return Ok(());
}
let list: Vec<ScheduledJob> = serde_json::from_str(&data).map_err(|e| {
SchedulerError::PersistError(format!("Failed to deserialize schedules.json: {}", e))
})?;
let mut jobs_guard = self.jobs.lock().await;
for job_to_load in list {
if !Path::new(&job_to_load.source).exists() {
tracing::warn!("Recipe file {} for scheduled job {} not found in shared store. Skipping job load.", job_to_load.source, job_to_load.id);
continue;
}
let job_for_task = job_to_load.clone();
let jobs_arc_for_task = self.jobs.clone();
let storage_path_for_task = self.storage_path.clone();
let running_tasks_for_task = self.running_tasks.clone();
tracing::info!(
"Loading job '{}' with cron expression: '{}'",
job_to_load.id,
job_to_load.cron
);
let normalized_cron = normalize_cron_expression(&job_to_load.cron);
// Convert from 7-field (Temporal format) to 6-field (tokio-cron-scheduler format)
let tokio_cron = {
let parts: Vec<&str> = normalized_cron.split_whitespace().collect();
if parts.len() == 7 {
parts[..6].join(" ")
} else {
normalized_cron.clone()
}
};
if tokio_cron != job_to_load.cron {
tracing::info!(
"Converted cron expression from '{}' to '{}' for tokio-cron-scheduler",
job_to_load.cron,
tokio_cron
);
}
let cron_task = Job::new_async(&tokio_cron, move |_uuid, _l| {
let task_job_id = job_for_task.id.clone();
let current_jobs_arc = jobs_arc_for_task.clone();
let local_storage_path = storage_path_for_task.clone();
let job_to_execute = job_for_task.clone(); // Clone for run_scheduled_job_internal
let running_tasks_arc = running_tasks_for_task.clone();
Box::pin(async move {
// Check if the job is paused before executing
let should_execute = {
let jobs_map_guard = current_jobs_arc.lock().await;
if let Some((_, stored_job)) = jobs_map_guard.get(&task_job_id) {
!stored_job.paused
} else {
false
}
};
if !should_execute {
tracing::info!("Skipping execution of paused job '{}'", &task_job_id);
return;
}
let current_time = Utc::now();
let mut needs_persist = false;
{
let mut jobs_map_guard = current_jobs_arc.lock().await;
if let Some((_, stored_job)) = jobs_map_guard.get_mut(&task_job_id) {
stored_job.last_run = Some(current_time);
stored_job.currently_running = true;
stored_job.process_start_time = Some(current_time);
needs_persist = true;
}
}
if needs_persist {
if let Err(e) =
persist_jobs_from_arc(&local_storage_path, &current_jobs_arc).await
{
tracing::error!(
"Failed to persist last_run update for loaded job {}: {}",
&task_job_id,
e
);
}
}
// Spawn the job execution as an abortable task
let job_task = tokio::spawn(run_scheduled_job_internal(
job_to_execute,
None,
Some(current_jobs_arc.clone()),
Some(task_job_id.clone()),
));
// Store the abort handle at the scheduler level
{
let mut running_tasks_guard = running_tasks_arc.lock().await;
running_tasks_guard.insert(task_job_id.clone(), job_task.abort_handle());
}
// Wait for the job to complete or be aborted
let result = job_task.await;
// Remove the abort handle
{
let mut running_tasks_guard = running_tasks_arc.lock().await;
running_tasks_guard.remove(&task_job_id);
}
// Update the job status after execution
{
let mut jobs_map_guard = current_jobs_arc.lock().await;
if let Some((_, stored_job)) = jobs_map_guard.get_mut(&task_job_id) {
stored_job.currently_running = false;
stored_job.current_session_id = None;
stored_job.process_start_time = None;
needs_persist = true;
}
}
if needs_persist {
if let Err(e) =
persist_jobs_from_arc(&local_storage_path, &current_jobs_arc).await
{
tracing::error!(
"Failed to persist running status update for job {}: {}",
&task_job_id,
e
);
}
}
match result {
Ok(Ok(_session_id)) => {
tracing::info!(
"Scheduled job '{}' completed successfully",
&task_job_id
);
}
Ok(Err(e)) => {
tracing::error!(
"Scheduled job '{}' execution failed: {}",
&e.job_id,
e.error
);
}
Err(join_error) if join_error.is_cancelled() => {
tracing::info!("Scheduled job '{}' was cancelled/killed", &task_job_id);
}
Err(join_error) => {
tracing::error!(
"Scheduled job '{}' task failed: {}",
&task_job_id,
join_error
);
}
}
})
})
.map_err(|e| SchedulerError::CronParseError(e.to_string()))?;
let job_uuid = self
.internal_scheduler
.add(cron_task)
.await
.map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?;
jobs_guard.insert(job_to_load.id.clone(), (job_uuid, job_to_load));
}
Ok(())
}
// Renamed and kept for direct use when a guard is already held (e.g. add/remove)
async fn persist_jobs_to_storage_with_guard(
&self,
jobs_guard: &tokio::sync::MutexGuard<'_, JobsMap>,
) -> Result<(), SchedulerError> {
let list: Vec<ScheduledJob> = jobs_guard.values().map(|(_, j)| j.clone()).collect();
if let Some(parent) = self.storage_path.parent() {
fs::create_dir_all(parent)?;
}
let data = serde_json::to_string_pretty(&list)?;
fs::write(&self.storage_path, data)?;
Ok(())
}
// New function that locks and calls the helper, for run_now and potentially other places
async fn persist_jobs(&self) -> Result<(), SchedulerError> {
persist_jobs_from_arc(&self.storage_path, &self.jobs).await
}
pub async fn list_scheduled_jobs(&self) -> Vec<ScheduledJob> {
self.jobs
.lock()
.await
.values()
.map(|(_, j)| j.clone())
.collect()
}
pub async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> {
let mut jobs_guard = self.jobs.lock().await;
if let Some((job_uuid, scheduled_job)) = jobs_guard.remove(id) {
self.internal_scheduler
.remove(&job_uuid)
.await
.map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?;
let recipe_path = Path::new(&scheduled_job.source);
if recipe_path.exists() {
fs::remove_file(recipe_path).map_err(SchedulerError::StorageError)?;
}
self.persist_jobs_to_storage_with_guard(&jobs_guard).await?;
Ok(())
} else {
Err(SchedulerError::JobNotFound(id.to_string()))
}
}
pub async fn sessions(
&self,
sched_id: &str,
limit: usize,
) -> Result<Vec<(String, Session)>, SchedulerError> {
let all_sessions = SessionManager::list_sessions()
.await
.map_err(|e| SchedulerError::StorageError(io::Error::other(e)))?;
let mut schedule_sessions: Vec<(String, Session)> = Vec::new();
for session in all_sessions {
if session.schedule_id.as_deref() == Some(sched_id) {
schedule_sessions.push((session.id.clone(), session));
}
}
schedule_sessions.sort_by(|a, b| b.0.cmp(&a.0));
let result_sessions: Vec<(String, Session)> =
schedule_sessions.into_iter().take(limit).collect();
Ok(result_sessions)
}
pub async fn run_now(&self, sched_id: &str) -> Result<String, SchedulerError> {
let job_to_run: ScheduledJob = {
let mut jobs_guard = self.jobs.lock().await;
match jobs_guard.get_mut(sched_id) {
Some((_, job_def)) => {
// Set the currently_running flag before executing
job_def.currently_running = true;
let job_clone = job_def.clone();
// Drop the guard before persisting to avoid borrow issues
drop(jobs_guard);
// Persist the change immediately
self.persist_jobs().await?;
job_clone
}
None => return Err(SchedulerError::JobNotFound(sched_id.to_string())),
}
};
// Spawn the job execution as an abortable task for run_now
let job_task = tokio::spawn(run_scheduled_job_internal(
job_to_run.clone(),
None,
Some(self.jobs.clone()),
Some(sched_id.to_string()),
));
// Store the abort handle for run_now jobs
{
let mut running_tasks_guard = self.running_tasks.lock().await;
running_tasks_guard.insert(sched_id.to_string(), job_task.abort_handle());
}
// Wait for the job to complete or be aborted
let run_result = job_task.await;
// Remove the abort handle
{
let mut running_tasks_guard = self.running_tasks.lock().await;
running_tasks_guard.remove(sched_id);
}
// Clear the currently_running flag after execution
{
let mut jobs_guard = self.jobs.lock().await;
if let Some((_tokio_job_id, job_in_map)) = jobs_guard.get_mut(sched_id) {
job_in_map.currently_running = false;
job_in_map.current_session_id = None;
job_in_map.process_start_time = None;
job_in_map.last_run = Some(Utc::now());
} // MutexGuard is dropped here
}
// Persist after the lock is released and update is made.
self.persist_jobs().await?;
match run_result {
Ok(Ok(session_id)) => Ok(session_id),
Ok(Err(e)) => Err(SchedulerError::AnyhowError(anyhow!(
"Failed to execute job '{}' immediately: {}",
sched_id,
e.error
))),
Err(join_error) if join_error.is_cancelled() => {
tracing::info!("Run now job '{}' was cancelled/killed", sched_id);
Err(SchedulerError::AnyhowError(anyhow!(
"Job '{}' was successfully cancelled",
sched_id
)))
}
Err(join_error) => Err(SchedulerError::AnyhowError(anyhow!(
"Failed to execute job '{}' immediately: {}",
sched_id,
join_error
))),
}
}
pub async fn pause_schedule(&self, sched_id: &str) -> Result<(), SchedulerError> {
let mut jobs_guard = self.jobs.lock().await;
match jobs_guard.get_mut(sched_id) {
Some((_, job_def)) => {
if job_def.currently_running {
return Err(SchedulerError::AnyhowError(anyhow!(
"Cannot pause schedule '{}' while it's currently running",
sched_id
)));
}
job_def.paused = true;
self.persist_jobs_to_storage_with_guard(&jobs_guard).await?;
Ok(())
}
None => Err(SchedulerError::JobNotFound(sched_id.to_string())),
}
}
pub async fn unpause_schedule(&self, sched_id: &str) -> Result<(), SchedulerError> {
let mut jobs_guard = self.jobs.lock().await;
match jobs_guard.get_mut(sched_id) {
Some((_, job_def)) => {
job_def.paused = false;
self.persist_jobs_to_storage_with_guard(&jobs_guard).await?;
Ok(())
}
None => Err(SchedulerError::JobNotFound(sched_id.to_string())),
}
}
pub async fn update_schedule(
&self,
sched_id: &str,
new_cron: String,
) -> Result<(), SchedulerError> {
let mut jobs_guard = self.jobs.lock().await;
match jobs_guard.get_mut(sched_id) {
Some((job_uuid, job_def)) => {
if job_def.currently_running {
return Err(SchedulerError::AnyhowError(anyhow!(
"Cannot edit schedule '{}' while it's currently running",
sched_id
)));
}
if new_cron == job_def.cron {
// No change needed
return Ok(());
}
// Remove the old job from the scheduler
self.internal_scheduler
.remove(job_uuid)
.await
.map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?;
// Create new job with updated cron
let job_for_task = job_def.clone();
let jobs_arc_for_task = self.jobs.clone();
let storage_path_for_task = self.storage_path.clone();
let running_tasks_for_task = self.running_tasks.clone();
tracing::info!(
"Updating job '{}' with new cron expression: '{}'",
sched_id,
new_cron
);
let normalized_cron = normalize_cron_expression(&new_cron);
// Convert from 7-field (Temporal format) to 6-field (tokio-cron-scheduler format)
let tokio_cron = {
let parts: Vec<&str> = normalized_cron.split_whitespace().collect();
if parts.len() == 7 {
parts[..6].join(" ")
} else {
normalized_cron.clone()
}
};
if tokio_cron != new_cron {
tracing::info!(
"Converted cron expression from '{}' to '{}' for tokio-cron-scheduler",
new_cron,
tokio_cron
);
}
let cron_task = Job::new_async(&tokio_cron, move |_uuid, _l| {
let task_job_id = job_for_task.id.clone();
let current_jobs_arc = jobs_arc_for_task.clone();
let local_storage_path = storage_path_for_task.clone();
let job_to_execute = job_for_task.clone();
let running_tasks_arc = running_tasks_for_task.clone();
Box::pin(async move {
// Check if the job is paused before executing
let should_execute = {
let jobs_map_guard = current_jobs_arc.lock().await;
if let Some((_, current_job_in_map)) = jobs_map_guard.get(&task_job_id)
{
!current_job_in_map.paused
} else {
false
}
};
if !should_execute {
tracing::info!("Skipping execution of paused job '{}'", &task_job_id);
return;
}
let current_time = Utc::now();
let mut needs_persist = false;
{
let mut jobs_map_guard = current_jobs_arc.lock().await;
if let Some((_, current_job_in_map)) =
jobs_map_guard.get_mut(&task_job_id)
{
current_job_in_map.last_run = Some(current_time);
current_job_in_map.currently_running = true;
current_job_in_map.process_start_time = Some(current_time);
needs_persist = true;
}
}
if needs_persist {
if let Err(e) =
persist_jobs_from_arc(&local_storage_path, &current_jobs_arc).await
{
tracing::error!(
"Failed to persist last_run update for job {}: {}",
&task_job_id,
e
);
}
}
// Spawn the job execution as an abortable task
let job_task = tokio::spawn(run_scheduled_job_internal(
job_to_execute,
None,
Some(current_jobs_arc.clone()),
Some(task_job_id.clone()),
));
// Store the abort handle at the scheduler level
{
let mut running_tasks_guard = running_tasks_arc.lock().await;
running_tasks_guard
.insert(task_job_id.clone(), job_task.abort_handle());
}
// Wait for the job to complete or be aborted
let result = job_task.await;
// Remove the abort handle
{
let mut running_tasks_guard = running_tasks_arc.lock().await;
running_tasks_guard.remove(&task_job_id);
}
// Update the job status after execution
{
let mut jobs_map_guard = current_jobs_arc.lock().await;
if let Some((_, current_job_in_map)) =
jobs_map_guard.get_mut(&task_job_id)
{
current_job_in_map.currently_running = false;
current_job_in_map.current_session_id = None;
current_job_in_map.process_start_time = None;
needs_persist = true;
}
}
if needs_persist {
if let Err(e) =
persist_jobs_from_arc(&local_storage_path, &current_jobs_arc).await
{
tracing::error!(
"Failed to persist running status update for job {}: {}",
&task_job_id,
e
);
}
}
match result {
Ok(Ok(_session_id)) => {
tracing::info!(
"Scheduled job '{}' completed successfully",
&task_job_id
);
}
Ok(Err(e)) => {
tracing::error!(
"Scheduled job '{}' execution failed: {}",
&e.job_id,
e.error
);
}
Err(join_error) if join_error.is_cancelled() => {
tracing::info!(
"Scheduled job '{}' was cancelled/killed",
&task_job_id
);
}
Err(join_error) => {
tracing::error!(
"Scheduled job '{}' task failed: {}",
&task_job_id,
join_error
);
}
}
})
})
.map_err(|e| SchedulerError::CronParseError(e.to_string()))?;
let new_job_uuid = self
.internal_scheduler
.add(cron_task)
.await
.map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?;
// Update the job UUID and cron expression
*job_uuid = new_job_uuid;
job_def.cron = new_cron;
self.persist_jobs_to_storage_with_guard(&jobs_guard).await?;
Ok(())
}
None => Err(SchedulerError::JobNotFound(sched_id.to_string())),
}
}
pub async fn kill_running_job(&self, sched_id: &str) -> Result<(), SchedulerError> {
let mut jobs_guard = self.jobs.lock().await;
match jobs_guard.get_mut(sched_id) {
Some((_, job_def)) => {
if !job_def.currently_running {
return Err(SchedulerError::AnyhowError(anyhow!(
"Schedule '{}' is not currently running",
sched_id
)));
}
tracing::info!("Killing running job '{}'", sched_id);
// Abort the running task if it exists
{
let mut running_tasks_guard = self.running_tasks.lock().await;
if let Some(abort_handle) = running_tasks_guard.remove(sched_id) {
abort_handle.abort();
tracing::info!("Aborted running task for job '{}'", sched_id);
} else {
tracing::warn!(
"No abort handle found for job '{}' in running tasks map",
sched_id
);
}
}
// Mark the job as no longer running
job_def.currently_running = false;
job_def.current_session_id = None;
job_def.process_start_time = None;
self.persist_jobs_to_storage_with_guard(&jobs_guard).await?;
tracing::info!("Successfully killed job '{}'", sched_id);
Ok(())
}
None => Err(SchedulerError::JobNotFound(sched_id.to_string())),
}
}
pub async fn get_running_job_info(
&self,
sched_id: &str,
) -> Result<Option<(String, DateTime<Utc>)>, SchedulerError> {
let jobs_guard = self.jobs.lock().await;
match jobs_guard.get(sched_id) {
Some((_, job_def)) => {
if job_def.currently_running {
if let (Some(session_id), Some(start_time)) =
(&job_def.current_session_id, &job_def.process_start_time)
{
Ok(Some((session_id.clone(), *start_time)))
} else {
Ok(None)
}
} else {
Ok(None)
}
}
None => Err(SchedulerError::JobNotFound(sched_id.to_string())),
}
}
}
#[derive(Debug)]
struct JobExecutionError {
job_id: String,
error: String,
}
async fn run_scheduled_job_internal(
job: ScheduledJob,
provider_override: Option<Arc<dyn GooseProvider>>,
jobs_arc: Option<Arc<Mutex<JobsMap>>>,
job_id: Option<String>,
) -> std::result::Result<String, JobExecutionError> {
tracing::info!("Executing job: {} (Source: {})", job.id, job.source);
let recipe_path = Path::new(&job.source);
let recipe_content = match fs::read_to_string(recipe_path) {
Ok(content) => content,
Err(e) => {
return Err(JobExecutionError {
job_id: job.id.clone(),
error: format!("Failed to load recipe file '{}': {}", job.source, e),
});
}
};
let recipe: Recipe = {
let extension = recipe_path
.extension()
.and_then(|os_str| os_str.to_str())
.unwrap_or("yaml")
.to_lowercase();
match extension.as_str() {
"json" | "jsonl" => {
serde_json::from_str::<Recipe>(&recipe_content).map_err(|e| JobExecutionError {
job_id: job.id.clone(),
error: format!("Failed to parse JSON recipe '{}': {}", job.source, e),
})
}
"yaml" | "yml" => {
serde_yaml::from_str::<Recipe>(&recipe_content).map_err(|e| JobExecutionError {
job_id: job.id.clone(),
error: format!("Failed to parse YAML recipe '{}': {}", job.source, e),
})
}
_ => Err(JobExecutionError {
job_id: job.id.clone(),
error: format!(
"Unsupported recipe file extension '{}' for: {}",
extension, job.source
),
}),
}
}?;
let agent: Agent = Agent::new();
let agent_provider: Arc<dyn GooseProvider>;
if let Some(provider) = provider_override {
agent_provider = provider;
} else {
let global_config = Config::global();
let provider_name: String = match global_config.get_param("GOOSE_PROVIDER") {
Ok(name) => name,
Err(_) => return Err(JobExecutionError {
job_id: job.id.clone(),
error:
"GOOSE_PROVIDER not configured globally. Run 'goose configure' or set env var."
.to_string(),
}),
};
let model_name: String =
match global_config.get_param("GOOSE_MODEL") {
Ok(name) => name,
Err(_) => return Err(JobExecutionError {
job_id: job.id.clone(),
error:
"GOOSE_MODEL not configured globally. Run 'goose configure' or set env var."
.to_string(),
}),
};
let model_config =
crate::model::ModelConfig::new(model_name.as_str()).map_err(|e| JobExecutionError {
job_id: job.id.clone(),
error: format!("Model config error: {}", e),
})?;
agent_provider =
create(&provider_name, model_config)
.await
.map_err(|e| JobExecutionError {
job_id: job.id.clone(),
error: format!(
"Failed to create provider instance '{}': {}",
provider_name, e
),
})?;
}
if let Some(ref recipe_extensions) = recipe.extensions {
for extension in recipe_extensions {
agent
.add_extension(extension.clone())
.await
.map_err(|e| JobExecutionError {
job_id: job.id.clone(),
error: format!("Failed to add extension '{}': {}", extension.name(), e),
})?;
}
}
if let Err(e) = agent.update_provider(agent_provider).await {
return Err(JobExecutionError {
job_id: job.id.clone(),
error: format!("Failed to set provider on agent: {}", e),
});
}
tracing::info!("Agent configured with provider for job '{}'", job.id);
let execution_mode = job.execution_mode.as_deref().unwrap_or("background");
tracing::info!("Job '{}' running in {} mode", job.id, execution_mode);
let current_dir = match std::env::current_dir() {
Ok(cd) => cd,
Err(e) => {
return Err(JobExecutionError {
job_id: job.id.clone(),
error: format!("Failed to get current directory for job execution: {}", e),
});
}
};
// Create session upfront for both cases
let session = match SessionManager::create_session(
current_dir.clone(),
if recipe.prompt.is_some() {
format!("Scheduled job: {}", job.id)
} else {
"Empty job - no prompt".to_string()
},
)
.await
{
Ok(s) => s,
Err(e) => {
return Err(JobExecutionError {
job_id: job.id.clone(),
error: format!("Failed to create session: {}", e),
});
}
};
// Update the job with the session ID if we have access to the jobs arc
if let (Some(jobs_arc), Some(job_id_str)) = (jobs_arc.as_ref(), job_id.as_ref()) {
let mut jobs_guard = jobs_arc.lock().await;
if let Some((_, job_def)) = jobs_guard.get_mut(job_id_str) {
job_def.current_session_id = Some(session.id.clone());
}
}
if let Some(ref prompt_text) = recipe.prompt {
let mut conversation =
Conversation::new_unvalidated(vec![Message::user().with_text(prompt_text.clone())]);
let session_config = SessionConfig {
id: session.id.clone(),
working_dir: current_dir.clone(),
schedule_id: Some(job.id.clone()),
execution_mode: job.execution_mode.clone(),
max_turns: None,
retry_config: None,
};
match agent
.reply(conversation.clone(), Some(session_config.clone()), None)
.await
{
Ok(mut stream) => {
use futures::StreamExt;
while let Some(message_result) = stream.next().await {
tokio::task::yield_now().await;
match message_result {
Ok(AgentEvent::Message(msg)) => {
if msg.role == rmcp::model::Role::Assistant {
tracing::info!("[Job {}] Assistant: {:?}", job.id, msg.content);
}
conversation.push(msg);
}
Ok(AgentEvent::McpNotification(_)) => {}
Ok(AgentEvent::ModelChange { .. }) => {}
Ok(AgentEvent::HistoryReplaced(updated_conversation)) => {
conversation = updated_conversation;
}
Err(e) => {
tracing::error!(
"[Job {}] Error receiving message from agent: {}",
job.id,
e
);
break;
}
}
}
}
Err(e) => {
return Err(JobExecutionError {
job_id: job.id.clone(),
error: format!("Agent failed to reply for recipe '{}': {}", job.source, e),
});
}
}
} else {
tracing::warn!(
"[Job {}] Recipe '{}' has no prompt to execute.",
job.id,
job.source
);
}
if let Err(e) = SessionManager::update_session(&session.id)
.schedule_id(Some(job.id.clone()))
.recipe(Some(recipe))
.apply()
.await
{
tracing::error!("[Job {}] Failed to update session metadata: {}", job.id, e);
}
tracing::info!("Finished job: {}", job.id);
Ok(session.id)
}
#[async_trait]
impl SchedulerTrait for Scheduler {
async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError> {
self.add_scheduled_job(job).await
}
async fn list_scheduled_jobs(&self) -> Result<Vec<ScheduledJob>, SchedulerError> {
Ok(self.list_scheduled_jobs().await)
}
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> {
self.remove_scheduled_job(id).await
}
async fn pause_schedule(&self, id: &str) -> Result<(), SchedulerError> {
self.pause_schedule(id).await
}
async fn unpause_schedule(&self, id: &str) -> Result<(), SchedulerError> {
self.unpause_schedule(id).await
}
async fn run_now(&self, id: &str) -> Result<String, SchedulerError> {
self.run_now(id).await
}
async fn sessions(
&self,
sched_id: &str,
limit: usize,
) -> Result<Vec<(String, Session)>, SchedulerError> {
self.sessions(sched_id, limit).await
}
async fn update_schedule(
&self,
sched_id: &str,
new_cron: String,
) -> Result<(), SchedulerError> {
self.update_schedule(sched_id, new_cron).await
}
async fn kill_running_job(&self, sched_id: &str) -> Result<(), SchedulerError> {
self.kill_running_job(sched_id).await
}
async fn get_running_job_info(
&self,
sched_id: &str,
) -> Result<Option<(String, DateTime<Utc>)>, SchedulerError> {
self.get_running_job_info(sched_id).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::recipe::Recipe;
use crate::{
model::ModelConfig,
providers::base::{ProviderMetadata, ProviderUsage, Usage},
providers::errors::ProviderError,
};
use rmcp::model::Tool;
use rmcp::model::{AnnotateAble, RawTextContent, Role};
use crate::conversation::message::{Message, MessageContent};
use std::env;
use std::fs::{self, File};
use std::io::Write;
use tempfile::tempdir;
#[derive(Clone)]
struct MockSchedulerTestProvider {
model_config: ModelConfig,
}
#[async_trait::async_trait]
impl GooseProvider for MockSchedulerTestProvider {
fn metadata() -> ProviderMetadata {
ProviderMetadata::new(
"mock-scheduler-test",
"Mock for Scheduler Test",
"A mock provider for scheduler tests", // description
"test-model", // default_model
vec!["test-model"], // model_names
"", // model_doc_link (empty string if not applicable)
vec![], // config_keys (empty vec if none)
)
}
fn get_model_config(&self) -> ModelConfig {
self.model_config.clone()
}
async fn complete_with_model(
&self,
_model_config: &ModelConfig,
_system: &str,
_messages: &[Message],
_tools: &[Tool],
) -> Result<(Message, ProviderUsage), ProviderError> {
Ok((
Message::new(
Role::Assistant,
Utc::now().timestamp(),
vec![MessageContent::Text(
RawTextContent {
text: "Mocked scheduled response".to_string(),
meta: None,
}
.no_annotation(),
)],
),
ProviderUsage::new("mock-scheduler-test".to_string(), Usage::default()),
))
}
}
// This function is pub(super) making it visible to run_scheduled_job_internal (parent module)
// when cfg(test) is active for the whole compilation unit.
pub(super) fn create_scheduler_test_mock_provider(
model_config: ModelConfig,
) -> Arc<dyn GooseProvider> {
Arc::new(MockSchedulerTestProvider { model_config })
}
#[tokio::test]
async fn test_scheduled_session_has_schedule_id() -> Result<(), Box<dyn std::error::Error>> {
// Set environment variables for the test
env::set_var("GOOSE_PROVIDER", "test_provider");
env::set_var("GOOSE_MODEL", "test_model");
let temp_dir = tempdir()?;
let recipe_dir = temp_dir.path().join("recipes_for_test_scheduler");
fs::create_dir_all(&recipe_dir)?;
let _ = crate::session::session_manager::ensure_session_dir()
.expect("Failed to ensure app session dir");
let schedule_id_str = "test_schedule_001_scheduler_check".to_string();
let recipe_filename = recipe_dir.join(format!("{}.json", schedule_id_str));
let dummy_recipe = Recipe {
version: "1.0.0".to_string(),
title: "Test Schedule ID Recipe".to_string(),
description: "A recipe for testing schedule_id propagation.".to_string(),
instructions: None,
prompt: Some("This is a test prompt for a scheduled job.".to_string()),
extensions: None,
context: None,
activities: None,
author: None,
parameters: None,
settings: None,
response: None,
sub_recipes: None,
retry: None,
};
let mut recipe_file = File::create(&recipe_filename)?;
writeln!(
recipe_file,
"{}",
serde_json::to_string_pretty(&dummy_recipe)?
)?;
recipe_file.flush()?;
drop(recipe_file);
let dummy_job = ScheduledJob {
id: schedule_id_str.clone(),
source: recipe_filename.to_string_lossy().into_owned(),
cron: "* * * * * * ".to_string(), // Runs every second for quick testing
last_run: None,
currently_running: false,
paused: false,
current_session_id: None,
process_start_time: None,
execution_mode: Some("background".to_string()), // Default for test
};
let mock_model_config = ModelConfig::new_or_fail("test_model");
let mock_provider_instance = create_scheduler_test_mock_provider(mock_model_config);
// Call run_scheduled_job_internal, passing the mock provider
let created_session_id =
run_scheduled_job_internal(dummy_job.clone(), Some(mock_provider_instance), None, None)
.await
.expect("run_scheduled_job_internal failed");
let session = SessionManager::get_session(&created_session_id, true).await?;
let schedule_id = session.schedule_id.clone();
assert_eq!(
schedule_id,
Some(schedule_id_str.clone()),
"Session metadata schedule_id ({:?}) does not match the job ID ({}). Session: {}",
schedule_id,
schedule_id_str,
created_session_id
);
// Check if messages were written using SessionManager
let messages_in_session = session.conversation.unwrap_or_default();
assert!(
!messages_in_session.is_empty(),
"No messages were written to the session: {}",
created_session_id
);
// We expect at least a user prompt and an assistant response
assert!(
messages_in_session.len() >= 2,
"Expected at least 2 messages (prompt + response), found {} in session: {}",
messages_in_session.len(),
created_session_id
);
// Clean up environment variables
env::remove_var("GOOSE_PROVIDER");
env::remove_var("GOOSE_MODEL");
Ok(())
}
}