vtcode_core/scheduler/
mod.rs1use 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}