vtcode_core/scheduler/
daemon.rs1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use std::path::PathBuf;
4use std::process::Stdio;
5use std::time::Duration;
6use tokio::process::Command;
7
8use super::advance_record_runtime;
9use super::session::resolve_scheduled_workspace_path;
10use super::spec::{ScheduledTaskAction, ScheduledTaskDefinition, ScheduledTaskRecord, TaskRunStatus};
11use super::store::{DurableTaskStore, release_claim_async, try_acquire_claim_async};
12use crate::notifications::{NotificationEvent, send_global_notification};
13use vtcode_commons::VtCodePaths;
14
15#[derive(Debug, Clone)]
16pub struct SchedulerDaemon {
17 store: DurableTaskStore,
18 executable_path: PathBuf,
19}
20
21impl SchedulerDaemon {
22 #[must_use]
23 pub fn new(store: DurableTaskStore, executable_path: PathBuf) -> Self {
24 Self { store, executable_path }
25 }
26
27 pub async fn serve_forever(&self) -> Result<()> {
28 loop {
29 self.run_due_tasks_once().await?;
30 tokio::time::sleep(Duration::from_secs(1)).await;
31 }
32 }
33
34 pub async fn run_due_tasks_once(&self) -> Result<usize> {
35 let now = Utc::now();
36 let mut records = self.store.load_records_async().await?;
37 let mut executed = 0usize;
38
39 records.sort_by_key(|record| record.runtime.next_run_at);
40 for mut record in records {
41 let Some(next_run_at) = record.runtime.next_run_at else {
42 continue;
43 };
44 if now < next_run_at {
45 continue;
46 }
47 if !try_acquire_claim_async(self.store.paths().clone(), record.definition.id.clone()).await? {
48 continue;
49 }
50
51 let result = self.execute_record(&record, now).await;
52 let release_result = release_claim_async(self.store.paths().clone(), record.definition.id.clone()).await;
53 let run_outcome = result?;
54 release_result?;
55
56 apply_run_outcome(&mut record, run_outcome)?;
57 self.store.update_runtime_async(record).await?;
58 executed = executed.saturating_add(1);
59 }
60
61 Ok(executed)
62 }
63
64 async fn execute_record(&self, record: &ScheduledTaskRecord, run_at: DateTime<Utc>) -> Result<RunOutcome> {
65 match &record.definition.action {
66 ScheduledTaskAction::Reminder { message } => {
67 let notification = send_global_notification(NotificationEvent::IdlePrompt {
68 title: format!("Scheduled reminder: {}", record.definition.name),
69 message: message.clone(),
70 })
71 .await;
72 let status = match notification {
73 Ok(()) => TaskRunStatus::ReminderSent,
74 Err(error) => TaskRunStatus::Failed {
75 message: format!("failed to send reminder notification: {error:#}"),
76 },
77 };
78 Ok(RunOutcome {
79 ran_at: run_at,
80 status,
81 artifact_dir: None,
82 events_file: None,
83 last_message_file: None,
84 })
85 }
86 ScheduledTaskAction::Prompt { prompt } => {
87 let artifact_dir = self.store.paths().artifact_dir(&record.definition.id, run_at);
88 let events_file = artifact_dir.join("events.jsonl");
89 let last_message_file = artifact_dir.join("last-message.txt");
90 let workspace_label = scheduled_workspace_label(&record.definition);
91 let workspace = resolve_scheduled_task_workspace(&record.definition);
92 let execution = async {
93 VtCodePaths::ensure_user_dir(&artifact_dir)
94 .with_context(|| format!("Failed to create run artifact dir {}", artifact_dir.display()))?;
95
96 let mut command = Command::new(&self.executable_path);
97 command
98 .arg("exec")
99 .arg("--events")
100 .arg(&events_file)
101 .arg("--last-message-file")
102 .arg(&last_message_file)
103 .arg(prompt)
104 .stdin(Stdio::null())
105 .stdout(Stdio::null())
106 .stderr(Stdio::null());
107
108 if let Some(workspace) = workspace? {
109 command.current_dir(&workspace);
110 }
111
112 command.status().await.with_context(|| {
113 format!(
114 "Failed to spawn scheduled VT Code exec for task {} using {} in {}",
115 record.definition.id,
116 self.executable_path.display(),
117 workspace_label
118 )
119 })
120 }
121 .await;
122 let run_status = match execution {
123 Ok(status) if status.success() => TaskRunStatus::Success,
124 Ok(status) => TaskRunStatus::Failed {
125 message: format!("vtcode exec exited with {status}"),
126 },
127 Err(error) => TaskRunStatus::Failed { message: format!("{error:#}") },
128 };
129 Ok(RunOutcome {
130 ran_at: run_at,
131 status: run_status,
132 artifact_dir: artifact_dir.is_dir().then_some(artifact_dir),
133 events_file: events_file.is_file().then_some(events_file),
134 last_message_file: last_message_file.is_file().then_some(last_message_file),
135 })
136 }
137 }
138 }
139}
140
141fn apply_run_outcome(record: &mut ScheduledTaskRecord, outcome: RunOutcome) -> Result<()> {
142 let RunOutcome {
143 ran_at,
144 status,
145 artifact_dir,
146 events_file,
147 last_message_file,
148 } = outcome;
149
150 record.runtime.last_artifact_dir = artifact_dir;
151 record.runtime.last_events_file = events_file;
152 record.runtime.last_message_file = last_message_file;
153
154 advance_record_runtime(record, ran_at, status)?;
155
156 Ok(())
157}
158
159#[derive(Debug, Clone)]
160struct RunOutcome {
161 ran_at: DateTime<Utc>,
162 status: TaskRunStatus,
163 artifact_dir: Option<PathBuf>,
164 events_file: Option<PathBuf>,
165 last_message_file: Option<PathBuf>,
166}
167
168fn scheduled_workspace_label(definition: &ScheduledTaskDefinition) -> String {
169 definition
170 .workspace
171 .as_ref()
172 .map(|path| path.display().to_string())
173 .unwrap_or_else(|| "<none>".to_string())
174}
175
176fn resolve_scheduled_task_workspace(definition: &ScheduledTaskDefinition) -> Result<Option<PathBuf>> {
177 definition
178 .workspace
179 .as_deref()
180 .map(resolve_scheduled_workspace_path)
181 .transpose()
182 .with_context(|| {
183 format!(
184 "Failed to resolve scheduled task workspace {} for task {}",
185 scheduled_workspace_label(definition),
186 definition.id
187 )
188 })
189}