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