Skip to content

Commit 6e8f35f

Browse files
committed
Add cron runner + tests
1 parent bec75cd commit 6e8f35f

6 files changed

Lines changed: 1125 additions & 16 deletions

File tree

src/cron_runner.rs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,160 @@
11
//! Tick loop: load jobs.json, find due jobs, execute (inbound to agent or direct sendMessage).
2+
3+
use std::sync::Arc;
4+
5+
use tokio::sync::mpsc;
6+
7+
use crate::telegram::{InboundMsg, OutboundMsg};
8+
use crate::tools::cron::{CronStore, JobAction};
9+
10+
fn unix_now() -> u64 {
11+
std::time::SystemTime::now()
12+
.duration_since(std::time::UNIX_EPOCH)
13+
.map(|d| d.as_secs())
14+
.unwrap_or(0)
15+
}
16+
17+
/// Run one tick: find due jobs, send to channels, mark fired. Used by runner and tests.
18+
pub async fn tick_once(
19+
store: &CronStore,
20+
inbound_tx: &mpsc::Sender<InboundMsg>,
21+
outbound_tx: &mpsc::Sender<OutboundMsg>,
22+
now: u64,
23+
) {
24+
let due = store.find_due(now);
25+
for job in due {
26+
match job.action {
27+
JobAction::Agent => {
28+
let msg = InboundMsg {
29+
chat_id: job.chat_id,
30+
user_id: 0,
31+
text: job.message.clone(),
32+
channel: "cron".to_string(),
33+
};
34+
if inbound_tx.try_send(msg).is_err() {
35+
eprintln!("cron runner: inbound channel full, dropping agent job {}", job.id);
36+
}
37+
}
38+
JobAction::Direct => {
39+
let msg = OutboundMsg {
40+
chat_id: job.chat_id,
41+
text: job.message.clone(),
42+
channel: "cron".to_string(),
43+
};
44+
if outbound_tx.try_send(msg).is_err() {
45+
eprintln!("cron runner: outbound channel full, dropping direct job {}", job.id);
46+
}
47+
}
48+
}
49+
store.mark_fired(&job.id, now);
50+
}
51+
}
52+
53+
async fn tick_loop(
54+
store: Arc<CronStore>,
55+
inbound_tx: mpsc::Sender<InboundMsg>,
56+
outbound_tx: mpsc::Sender<OutboundMsg>,
57+
tick_secs: u64,
58+
) {
59+
let mut interval = tokio::time::interval(std::time::Duration::from_secs(tick_secs));
60+
interval.tick().await;
61+
loop {
62+
interval.tick().await;
63+
let now = unix_now();
64+
tick_once(&store, &inbound_tx, &outbound_tx, now).await;
65+
}
66+
}
67+
68+
/// Spawns the cron runner task. Returns the join handle (caller may ignore).
69+
pub fn spawn_cron_runner(
70+
store: Arc<CronStore>,
71+
inbound_tx: mpsc::Sender<InboundMsg>,
72+
outbound_tx: mpsc::Sender<OutboundMsg>,
73+
tick_interval_secs: u64,
74+
) -> tokio::task::JoinHandle<()> {
75+
tokio::spawn(async move {
76+
tick_loop(store, inbound_tx, outbound_tx, tick_interval_secs).await;
77+
})
78+
}
79+
80+
#[cfg(test)]
81+
mod tests {
82+
use super::*;
83+
use crate::tools::cron::{CronStore, Schedule};
84+
85+
#[tokio::test]
86+
async fn tick_fires_due_direct_job() {
87+
let dir = std::env::temp_dir().join("icrab_cron_runner_direct");
88+
let _ = std::fs::remove_dir_all(&dir);
89+
std::fs::create_dir_all(&dir).unwrap();
90+
let store = CronStore::empty(&dir);
91+
store
92+
.add(
93+
None,
94+
"Reminder".to_string(),
95+
JobAction::Direct,
96+
Schedule::Once { at_unix: 100 },
97+
12345,
98+
)
99+
.unwrap();
100+
let (inbound_tx, _inbound_rx) = mpsc::channel(8);
101+
let (outbound_tx, mut outbound_rx) = mpsc::channel(8);
102+
tick_once(&store, &inbound_tx, &outbound_tx, 500).await;
103+
let msg = outbound_rx.try_recv().unwrap();
104+
assert_eq!(msg.chat_id, 12345);
105+
assert_eq!(msg.text, "Reminder");
106+
assert_eq!(msg.channel, "cron");
107+
let job = store.get("job-1").unwrap();
108+
assert!(job.last_run.is_some());
109+
assert!(!job.enabled);
110+
let _ = std::fs::remove_dir_all(&dir);
111+
}
112+
113+
#[tokio::test]
114+
async fn tick_fires_due_agent_job() {
115+
let dir = std::env::temp_dir().join("icrab_cron_runner_agent");
116+
let _ = std::fs::remove_dir_all(&dir);
117+
std::fs::create_dir_all(&dir).unwrap();
118+
let store = CronStore::empty(&dir);
119+
store
120+
.add(
121+
None,
122+
"Agent task".to_string(),
123+
JobAction::Agent,
124+
Schedule::Once { at_unix: 100 },
125+
999,
126+
)
127+
.unwrap();
128+
let (inbound_tx, mut inbound_rx) = mpsc::channel(8);
129+
let (outbound_tx, _outbound_rx) = mpsc::channel(8);
130+
tick_once(&store, &inbound_tx, &outbound_tx, 500).await;
131+
let msg = inbound_rx.try_recv().unwrap();
132+
assert_eq!(msg.chat_id, 999);
133+
assert_eq!(msg.text, "Agent task");
134+
assert_eq!(msg.channel, "cron");
135+
assert_eq!(msg.user_id, 0);
136+
let _ = std::fs::remove_dir_all(&dir);
137+
}
138+
139+
#[tokio::test]
140+
async fn tick_skips_not_due() {
141+
let dir = std::env::temp_dir().join("icrab_cron_runner_skip");
142+
let _ = std::fs::remove_dir_all(&dir);
143+
std::fs::create_dir_all(&dir).unwrap();
144+
let store = CronStore::empty(&dir);
145+
store
146+
.add(
147+
None,
148+
"Later".to_string(),
149+
JobAction::Direct,
150+
Schedule::Once { at_unix: 99999 },
151+
1,
152+
)
153+
.unwrap();
154+
let (inbound_tx, _inbound_rx) = mpsc::channel(8);
155+
let (outbound_tx, mut outbound_rx) = mpsc::channel(8);
156+
tick_once(&store, &inbound_tx, &outbound_tx, 500).await;
157+
assert!(outbound_rx.try_recv().is_err());
158+
let _ = std::fs::remove_dir_all(&dir);
159+
}
160+
}

src/main.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
use std::path::PathBuf;
66
use std::sync::Arc;
77

8+
use tokio::sync::mpsc;
9+
810
use icrab::agent;
911
use icrab::agent::subagent_manager::SubagentManager;
1012
use icrab::config;
13+
use icrab::cron_runner;
1114
use icrab::llm::HttpProvider;
1215
use icrab::telegram::{self, OutboundMsg};
1316
use icrab::tools;
17+
use icrab::tools::cron::{CronStore, CronTool};
1418
use icrab::tools::spawn::SpawnTool;
1519
use icrab::tools::subagent::SubagentTool;
1620

@@ -57,14 +61,30 @@ async fn main() {
5761
SUBAGENT_MAX_ITERATIONS,
5862
));
5963

60-
// Main registry: core + spawn tool.
64+
// Main registry: core + spawn + cron (cron is main-agent-only).
6165
let registry = tools::build_core_registry(&cfg);
6266
registry.register(SpawnTool::new(Arc::clone(&manager)));
6367
registry.register(SubagentTool::new(Arc::clone(&manager)));
6468

65-
let (mut inbound_rx, outbound_tx) = telegram::spawn_telegram(&cfg);
69+
let (inbound_tx, mut inbound_rx) = mpsc::channel(64);
70+
let outbound_tx = telegram::spawn_telegram(&cfg, inbound_tx.clone());
6671
eprintln!("Telegram poller and sender started");
6772

73+
let cron_store = Arc::new(
74+
CronStore::load(&workspace).unwrap_or_else(|e| {
75+
eprintln!("cron store: {}", e);
76+
CronStore::empty(&workspace)
77+
}),
78+
);
79+
cron_runner::spawn_cron_runner(
80+
Arc::clone(&cron_store),
81+
inbound_tx.clone(),
82+
outbound_tx.clone(),
83+
60,
84+
);
85+
registry.register(CronTool::new(Arc::clone(&cron_store)));
86+
drop(inbound_tx);
87+
6888
while let Some(msg) = inbound_rx.recv().await {
6989
let tool_ctx = tools::ToolCtx {
7090
workspace: workspace.clone(),

src/telegram.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -321,18 +321,21 @@ async fn send_loop(client: TelegramClient, mut outbound_rx: mpsc::Receiver<Outbo
321321
}
322322
}
323323

324-
/// Spawns the Telegram poll task and send task; returns channels for main/agent.
324+
/// Spawns the Telegram poll task and send task; returns outbound sender.
325325
///
326-
/// Main holds `inbound_rx` and `outbound_tx`. Poll loop pushes allowed user messages to inbound;
327-
/// main/agent sends replies via outbound_tx. Shutdown in v1: process kill; later add cancel token.
328-
pub fn spawn_telegram(config: &Config) -> (mpsc::Receiver<InboundMsg>, mpsc::Sender<OutboundMsg>) {
326+
/// Caller creates the inbound channel and passes `inbound_tx` so other producers (e.g. cron runner)
327+
/// can inject messages. Poll loop pushes allowed user messages to inbound; main/agent sends
328+
/// replies via returned outbound_tx. Shutdown in v1: process kill; later add cancel token.
329+
pub fn spawn_telegram(
330+
config: &Config,
331+
inbound_tx: mpsc::Sender<InboundMsg>,
332+
) -> mpsc::Sender<OutboundMsg> {
329333
let telegram = config.telegram.as_ref().expect("config validated");
330334
let bot_token = telegram.bot_token.clone().expect("config validated");
331335
let allowed_user_ids = telegram.allowed_user_ids.clone();
332336
let api_base = telegram.api_base.as_deref();
333337

334338
let client = TelegramClient::with_base_url(&bot_token, api_base);
335-
let (inbound_tx, inbound_rx) = mpsc::channel(CHANNEL_CAP);
336339
let (outbound_tx, outbound_rx) = mpsc::channel(CHANNEL_CAP);
337340

338341
let poll_client = TelegramClient {
@@ -347,5 +350,5 @@ pub fn spawn_telegram(config: &Config) -> (mpsc::Receiver<InboundMsg>, mpsc::Sen
347350
send_loop(client, outbound_rx).await;
348351
});
349352

350-
(inbound_rx, outbound_tx)
353+
outbound_tx
351354
}

0 commit comments

Comments
 (0)