Skip to main content

vtcode_core/scheduler/
spec.rs

1use anyhow::{Context, Result, bail};
2use chrono::{DateTime, Duration as ChronoDuration, Local, Timelike, Utc};
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::path::PathBuf;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::Duration;
8
9use super::cron::ParsedCron;
10
11const SESSION_JITTER_CAP_SECS: u64 = 15 * 60;
12const ONE_SHOT_TOP_OF_HOUR_JITTER_SECS: u64 = 90;
13
14static NEXT_TASK_COUNTER: AtomicU64 = AtomicU64::new(1);
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(tag = "kind", rename_all = "snake_case")]
18pub enum ScheduledTaskAction {
19    Prompt { prompt: String },
20    Reminder { message: String },
21}
22
23impl ScheduledTaskAction {
24    #[must_use]
25    pub fn summary(&self) -> &str {
26        match self {
27            Self::Prompt { prompt } => prompt,
28            Self::Reminder { message } => message,
29        }
30    }
31
32    #[must_use]
33    pub fn kind_label(&self) -> &'static str {
34        match self {
35            Self::Prompt { .. } => "prompt",
36            Self::Reminder { .. } => "reminder",
37        }
38    }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(tag = "type", rename_all = "snake_case")]
43pub enum ScheduleSpec {
44    Cron5(Cron5),
45    FixedInterval(FixedInterval),
46    OneShot(OneShot),
47}
48
49impl ScheduleSpec {
50    pub fn cron5(expression: impl Into<String>) -> Result<Self> {
51        Ok(Self::Cron5(Cron5::parse(expression)?))
52    }
53
54    pub fn fixed_interval(duration: Duration) -> Result<Self> {
55        Ok(Self::FixedInterval(FixedInterval::from_duration(duration)?))
56    }
57
58    pub fn one_shot(at: DateTime<Utc>) -> Self {
59        Self::OneShot(OneShot { at })
60    }
61
62    #[must_use]
63    pub fn is_recurring(&self) -> bool {
64        !matches!(self, Self::OneShot(_))
65    }
66
67    #[must_use]
68    pub fn human_description(&self) -> String {
69        match self {
70            Self::Cron5(spec) => format!("cron {}", spec.expression),
71            Self::FixedInterval(spec) => spec.human_description(),
72            Self::OneShot(spec) => {
73                format!("once at {}", spec.at.with_timezone(&Local).format("%Y-%m-%d %H:%M"))
74            }
75        }
76    }
77
78    pub fn first_base_fire_at(&self, created_at: DateTime<Utc>) -> Result<Option<DateTime<Utc>>> {
79        let created_local = created_at.with_timezone(&Local);
80        let base_local = match self {
81            Self::Cron5(spec) => spec.next_after(created_local)?,
82            Self::FixedInterval(spec) => Some(created_local + spec.chrono_duration()?),
83            Self::OneShot(spec) => Some(spec.at.with_timezone(&Local)),
84        };
85        Ok(base_local.map(|value| value.with_timezone(&Utc)))
86    }
87
88    pub fn next_base_fire_after(&self, last_base_fire_at: DateTime<Utc>) -> Result<Option<DateTime<Utc>>> {
89        let last_base_local = last_base_fire_at.with_timezone(&Local);
90        let next_local = match self {
91            Self::Cron5(spec) => spec.next_after(last_base_local)?,
92            Self::FixedInterval(spec) => Some(last_base_local + spec.chrono_duration()?),
93            Self::OneShot(_) => None,
94        };
95        Ok(next_local.map(|value| value.with_timezone(&Utc)))
96    }
97
98    pub(crate) fn jittered_fire_at(&self, id: &str, base_fire_at: DateTime<Utc>) -> Result<DateTime<Utc>> {
99        let base_local = base_fire_at.with_timezone(&Local);
100        let hash = stable_hash_u64(id.as_bytes());
101        match self {
102            Self::Cron5(_) | Self::FixedInterval(_) => {
103                let Some(period) = self.nominal_period()? else {
104                    return Ok(base_fire_at);
105                };
106                #[allow(
107                    clippy::cast_sign_loss,
108                    reason = "Intentional compatibility, platform, or test-only suppression."
109                )]
110                let period_secs = period.num_seconds().max(0) as u64;
111                #[allow(
112                    clippy::cast_sign_loss,
113                    reason = "Intentional compatibility, platform, or test-only suppression."
114                )]
115                let max_delay = (((period_secs as f64) * 0.10).floor()).max(0.0) as u64;
116                let max_delay = max_delay.min(SESSION_JITTER_CAP_SECS);
117                let delay_secs = if max_delay == 0 { 0 } else { hash % (max_delay + 1) };
118                Ok(base_fire_at + ChronoDuration::seconds(delay_secs as i64))
119            }
120            Self::OneShot(_) if matches!(base_local.minute(), 0 | 30) => {
121                let lead_secs = hash % (ONE_SHOT_TOP_OF_HOUR_JITTER_SECS + 1);
122                Ok(base_fire_at - ChronoDuration::seconds(lead_secs as i64))
123            }
124            Self::OneShot(_) => Ok(base_fire_at),
125        }
126    }
127
128    fn nominal_period(&self) -> Result<Option<ChronoDuration>> {
129        match self {
130            Self::FixedInterval(spec) => Ok(Some(spec.chrono_duration()?)),
131            Self::Cron5(spec) => spec.approx_period(),
132            Self::OneShot(_) => Ok(None),
133        }
134    }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138pub struct Cron5 {
139    pub expression: String,
140}
141
142impl Cron5 {
143    pub fn parse(expression: impl Into<String>) -> Result<Self> {
144        let expression = expression.into();
145        ParsedCron::parse(&expression)?;
146        Ok(Self { expression })
147    }
148
149    pub(crate) fn parsed(&self) -> Result<ParsedCron> {
150        ParsedCron::parse(&self.expression)
151    }
152
153    pub(crate) fn next_after(&self, after: DateTime<Local>) -> Result<Option<DateTime<Local>>> {
154        self.parsed()?.next_after(after)
155    }
156
157    pub(crate) fn approx_period(&self) -> Result<Option<ChronoDuration>> {
158        let now = Local::now();
159        let Some(first) = self.next_after(now)? else {
160            return Ok(None);
161        };
162        let Some(second) = self.next_after(first)? else {
163            return Ok(None);
164        };
165        Ok(Some(second - first))
166    }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
170pub struct FixedInterval {
171    pub seconds: u64,
172}
173
174impl FixedInterval {
175    pub fn from_duration(duration: Duration) -> Result<Self> {
176        if duration.as_secs() < 60 {
177            bail!("Fixed intervals must be at least 1 minute");
178        }
179        Ok(Self { seconds: duration.as_secs() })
180    }
181
182    pub fn chrono_duration(&self) -> Result<ChronoDuration> {
183        let seconds = i64::try_from(self.seconds).context("interval is too large")?;
184        Ok(ChronoDuration::seconds(seconds))
185    }
186
187    #[must_use]
188    pub fn human_description(&self) -> String {
189        humanize_interval(self.seconds)
190    }
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
194pub struct OneShot {
195    pub at: DateTime<Utc>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
199pub struct ScheduledTaskDefinition {
200    pub id: String,
201    pub name: String,
202    pub schedule: ScheduleSpec,
203    pub action: ScheduledTaskAction,
204    pub workspace: Option<PathBuf>,
205    pub created_at: DateTime<Utc>,
206    pub expires_at: Option<DateTime<Utc>>,
207}
208
209impl ScheduledTaskDefinition {
210    pub fn new(
211        name: Option<String>,
212        schedule: ScheduleSpec,
213        action: ScheduledTaskAction,
214        workspace: Option<PathBuf>,
215        created_at: DateTime<Utc>,
216        expires_at: Option<DateTime<Utc>>,
217    ) -> Result<Self> {
218        let rendered_name = name
219            .map(|value| value.trim().to_string())
220            .filter(|value| !value.is_empty())
221            .unwrap_or_else(|| summarize_task_name(action.summary()));
222        let id = generate_task_id(&rendered_name, action.summary(), created_at);
223        Ok(Self {
224            id,
225            name: rendered_name,
226            schedule,
227            action,
228            workspace,
229            created_at,
230            expires_at,
231        })
232    }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
236#[serde(tag = "state", rename_all = "snake_case")]
237pub enum TaskRunStatus {
238    Triggered,
239    ReminderSent,
240    Success,
241    Failed { message: String },
242}
243
244impl fmt::Display for TaskRunStatus {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        match self {
247            Self::Triggered => write!(f, "triggered"),
248            Self::ReminderSent => write!(f, "reminder_sent"),
249            Self::Success => write!(f, "success"),
250            Self::Failed { message } => write!(f, "failed: {message}"),
251        }
252    }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
256pub struct ScheduledTaskRuntimeState {
257    pub last_run_at: Option<DateTime<Utc>>,
258    pub next_base_run_at: Option<DateTime<Utc>>,
259    pub next_run_at: Option<DateTime<Utc>>,
260    pub last_status: Option<TaskRunStatus>,
261    pub last_artifact_dir: Option<PathBuf>,
262    pub last_events_file: Option<PathBuf>,
263    pub last_message_file: Option<PathBuf>,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct ScheduledTaskRecord {
268    pub definition: ScheduledTaskDefinition,
269    pub runtime: ScheduledTaskRuntimeState,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
273pub struct ScheduledTaskSummary {
274    pub id: String,
275    pub name: String,
276    pub action_kind: String,
277    pub schedule: String,
278    pub workspace: Option<PathBuf>,
279    pub recurring: bool,
280    pub next_run_at: Option<DateTime<Utc>>,
281    pub last_run_at: Option<DateTime<Utc>>,
282    pub last_status: Option<String>,
283}
284
285impl ScheduledTaskRecord {
286    pub(crate) fn summary(&self) -> ScheduledTaskSummary {
287        ScheduledTaskSummary {
288            id: self.definition.id.clone(),
289            name: self.definition.name.clone(),
290            action_kind: self.definition.action.kind_label().to_string(),
291            schedule: self.definition.schedule.human_description(),
292            workspace: self.definition.workspace.clone(),
293            recurring: self.definition.schedule.is_recurring(),
294            next_run_at: self.runtime.next_run_at,
295            last_run_at: self.runtime.last_run_at,
296            last_status: self.runtime.last_status.as_ref().map(ToString::to_string),
297        }
298    }
299}
300
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct DueSessionPrompt {
303    pub id: String,
304    pub name: String,
305    pub prompt: String,
306}
307
308fn summarize_task_name(summary: &str) -> String {
309    let trimmed = summary.trim();
310    if trimmed.is_empty() {
311        return "Scheduled task".to_string();
312    }
313    let compact = vtcode_commons::formatting::collapse_whitespace(trimmed);
314    let mut output = String::new();
315    for ch in compact.chars().take(32) {
316        output.push(ch);
317    }
318    output
319}
320
321fn generate_task_id(name: &str, summary: &str, created_at: DateTime<Utc>) -> String {
322    let counter = NEXT_TASK_COUNTER.fetch_add(1, Ordering::Relaxed);
323    let seed = format!(
324        "{name}|{summary}|{}|{}|{}",
325        created_at.timestamp_nanos_opt().unwrap_or_default(),
326        std::process::id(),
327        counter
328    );
329    format!("{:08x}", stable_hash_u64(seed.as_bytes()) as u32)
330}
331
332fn stable_hash_u64(bytes: &[u8]) -> u64 {
333    let mut hash = 0xcbf29ce484222325u64;
334    for byte in bytes {
335        hash ^= u64::from(*byte);
336        hash = hash.wrapping_mul(0x100000001b3);
337    }
338    hash
339}
340
341fn humanize_interval(seconds: u64) -> String {
342    match seconds {
343        value if value % 86_400 == 0 => {
344            let days = value / 86_400;
345            if days == 1 {
346                "every 1 day".to_string()
347            } else {
348                format!("every {days} days")
349            }
350        }
351        value if value % 3_600 == 0 => {
352            let hours = value / 3_600;
353            if hours == 1 {
354                "every 1 hour".to_string()
355            } else {
356                format!("every {hours} hours")
357            }
358        }
359        value => {
360            let minutes = value / 60;
361            if minutes == 1 {
362                "every 1 minute".to_string()
363            } else {
364                format!("every {minutes} minutes")
365            }
366        }
367    }
368}