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