Skip to main content

vtcode_core/scheduler/
mod.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use std::path::Path;
4use vtcode_commons::VtCodePaths;
5use vtcode_commons::utils::parse_bool_env_value;
6
7pub const MAX_SCHEDULED_TASKS: usize = 50;
8pub const SESSION_TASK_EXPIRY_HOURS: i64 = 72;
9pub const DISABLE_CRON_ENV: &str = "VTCODE_DISABLE_CRON";
10pub const DURABLE_SCHEDULER_RUNTIME_HINT: &str = "Durable tasks fire while VT Code is open, `vtcode schedule serve` is running, or the installed scheduler service is active.";
11
12const SERVICE_NAME: &str = "vtcode-scheduler";
13const LAUNCHD_LABEL: &str = "com.vtcode.scheduler";
14
15#[cfg(test)]
16mod test_env_overrides {
17    use std::sync::{LazyLock, Mutex};
18
19    static DISABLE_CRON: LazyLock<Mutex<Option<String>>> = LazyLock::new(|| Mutex::new(None));
20
21    pub(super) fn get() -> Option<String> {
22        DISABLE_CRON.lock().unwrap_or_else(|e| e.into_inner()).clone()
23    }
24
25    pub(super) fn set(value: Option<&str>) {
26        if let Ok(mut slot) = DISABLE_CRON.lock() {
27            *slot = value.map(ToString::to_string);
28        }
29    }
30}
31
32#[cfg(test)]
33mod tests;
34
35mod cron;
36mod daemon;
37mod service;
38mod session;
39mod spec;
40mod store;
41
42pub use self::daemon::SchedulerDaemon;
43pub use self::service::{
44    ServiceInstallPlan, ServiceManager, install_service_file, render_launchd_plist, render_service_install_plan,
45    render_systemd_unit, uninstall_service_file,
46};
47#[cfg(test)]
48pub(crate) use self::session::resolve_scheduled_workspace_path_with_home;
49pub use self::session::{
50    ScheduleCreateInput, SessionLanguageCommand, SessionScheduler, parse_local_datetime, parse_schedule_create_args,
51    parse_schedule_create_tokens, parse_session_language_command,
52};
53pub use self::spec::{
54    Cron5, DueSessionPrompt, FixedInterval, OneShot, ScheduleSpec, ScheduledTaskAction, ScheduledTaskDefinition,
55    ScheduledTaskRecord, ScheduledTaskRuntimeState, ScheduledTaskSummary, TaskRunStatus,
56};
57pub use self::store::{DurableTaskStore, SchedulerPaths};
58
59pub fn scheduled_tasks_enabled(enabled_in_config: bool) -> bool {
60    #[cfg(test)]
61    if let Some(value) = test_env_overrides::get() {
62        if parse_bool_env_value(&value) == Some(true) {
63            return false;
64        }
65    }
66
67    if let Ok(value) = std::env::var(DISABLE_CRON_ENV) {
68        if parse_bool_env_value(&value) == Some(true) {
69            return false;
70        }
71    }
72    enabled_in_config
73}
74
75#[must_use]
76pub fn durable_task_is_overdue(
77    next_run_at: Option<DateTime<Utc>>,
78    last_run_at: Option<DateTime<Utc>>,
79    has_last_status: bool,
80    now: DateTime<Utc>,
81) -> bool {
82    !has_last_status && last_run_at.is_none() && next_run_at.is_some_and(|next_run| next_run <= now)
83}
84
85fn initialize_runtime_state(definition: &ScheduledTaskDefinition) -> Result<ScheduledTaskRuntimeState> {
86    let next_base_run_at = definition.schedule.first_base_fire_at(definition.created_at)?;
87    let next_run_at = next_base_run_at
88        .map(|base| definition.schedule.jittered_fire_at(&definition.id, base))
89        .transpose()?;
90    Ok(ScheduledTaskRuntimeState {
91        next_base_run_at,
92        next_run_at,
93        ..ScheduledTaskRuntimeState::default()
94    })
95}
96
97#[derive(Debug, Clone, Copy)]
98struct NextScheduledRun {
99    base_fire_at: DateTime<Utc>,
100    fire_at: DateTime<Utc>,
101}
102
103fn due_session_prompt(record: &ScheduledTaskRecord) -> Option<DueSessionPrompt> {
104    let ScheduledTaskAction::Prompt { prompt } = &record.definition.action else {
105        return None;
106    };
107
108    Some(DueSessionPrompt {
109        id: record.definition.id.clone(),
110        name: record.definition.name.clone(),
111        prompt: prompt.clone(),
112    })
113}
114
115fn next_scheduled_run(
116    definition: &ScheduledTaskDefinition,
117    last_base_run_at: Option<DateTime<Utc>>,
118) -> Result<Option<NextScheduledRun>> {
119    let Some(last_base_run_at) = last_base_run_at else {
120        return Ok(None);
121    };
122
123    let Some(next_base_run_at) = definition.schedule.next_base_fire_after(last_base_run_at)? else {
124        return Ok(None);
125    };
126
127    if definition.expires_at.is_some_and(|expiry| next_base_run_at > expiry) {
128        return Ok(None);
129    }
130
131    Ok(Some(NextScheduledRun {
132        fire_at: definition.schedule.jittered_fire_at(&definition.id, next_base_run_at)?,
133        base_fire_at: next_base_run_at,
134    }))
135}
136
137fn advance_record_runtime(
138    record: &mut ScheduledTaskRecord,
139    ran_at: DateTime<Utc>,
140    status: TaskRunStatus,
141) -> Result<bool> {
142    record.runtime.last_run_at = Some(ran_at);
143    record.runtime.last_status = Some(status);
144
145    match next_scheduled_run(&record.definition, record.runtime.next_base_run_at)? {
146        Some(next_run) => {
147            record.runtime.next_base_run_at = Some(next_run.base_fire_at);
148            record.runtime.next_run_at = Some(next_run.fire_at);
149            Ok(false)
150        }
151        None => {
152            record.runtime.next_base_run_at = None;
153            record.runtime.next_run_at = None;
154            Ok(true)
155        }
156    }
157}
158
159fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
160    VtCodePaths::write_private_file_atomic(path, content)
161        .with_context(|| format!("Failed to atomically write {}", path.display()))
162}