Task Scheduling
Cron jobs, heartbeat probes, and the shared infrastructure that drives scheduled agent tasks from the Aleph daemon.
The tasks module covers Aleph's time-based scheduling: cron for recurring agent jobs, heartbeat for periodic external probes with L1→L2 escalation, and a shared infrastructure layer (clock abstraction, delivery, schedule parsing, persistence, reaper) that both subsystems use. The daemon process owns the timer loops and is the canonical host for cron and heartbeat RPCs.
Design Philosophy
The scheduling system follows three principles:
- Testable time — Every time-dependent module accepts a
Clocktrait so tests inject aFakeClockand the production code usesSystemClock(Utc::now()). - Atomic persistence — Both
CronStoreandHeartbeatStoreare SQLite-backed with an in-memory cache; changes are applied in memory and persisted onpersist(), so the on-disk row always matches the live view the timer loop saw. - Push, not poll — Successful mutations publish
CronJobChanged/HeartbeatTaskChangedon the gateway event bus so the panel can drop its refresh loop.
Cron Jobs
The tasks::cron module schedules recurring jobs. A CronService wraps an internal ServiceState and exposes the public async API that gateway handlers and CLI tools call.
┌──────────────────────────────────────────────────────────┐
│ CronService │
├──────────────────────────────────────────────────────────┤
│ state: Arc<ServiceState<SystemClock>> │
│ ├─ store: Arc<Mutex<CronStore>> (SQLite + in-mem) │
│ ├─ clock: Arc<dyn Clock> │
│ └─ config: CronConfig │
│ event_bus: Option<Arc<GatewayEventBus>> (CronJobChanged)│
└──────────────────────────────────────────────────────────┘
│ ▲
▼ │
gateway handlers ◄─────handle_*──────────┘
(cron.list, cron.get, cron.create, cron.update,
cron.delete, cron.status, cron.run, cron.runs,
cron.toggle)Schedule kinds
A CronJob carries one of three schedule kinds (src/tasks/cron/config.rs):
pub enum ScheduleKind {
/// Fire at a specific timestamp.
At { at: i64, delete_after_run: bool },
/// Fire every N milliseconds (with optional anchor).
Every { every_ms: i64, anchor_ms: Option<i64> },
/// Standard 6-field cron expression with optional tz / stagger.
Cron { expr: String, tz: Option<String>, stagger_ms: Option<i64> },
}Public API
impl CronService {
pub fn new(config: CronConfig) -> Result<Self, String>;
pub fn with_event_bus(self, bus: Arc<GatewayEventBus>) -> Self;
pub async fn list_jobs(&self) -> Result<Vec<CronJobView>, String>;
pub async fn get_job(&self, id: &str) -> Result<CronJobView, String>;
pub async fn add_job(&self, job: CronJob) -> Result<String, String>;
pub async fn update_job(&self, id: &str, updates: CronJobUpdates) -> Result<(), String>;
pub async fn delete_job(&self, id: &str) -> Result<(), String>;
pub async fn enable_job(&self, id: &str) -> Result<(), String>;
pub async fn disable_job(&self, id: &str) -> Result<(), String>;
pub async fn toggle_job(&self, id: &str) -> Result<bool, String>;
pub async fn run_job(&self, id: &str) -> Result<(), String>;
pub async fn reap_history(&self) -> Result<u64, String>;
}Features
- Schedule kinds — one-shot (
At), interval (Every), and standard 6-field cron expressions with optional timezone and per-job stagger. - JSON atomic persistence with in-memory cache;
persist()writes the in-memory working copy to SQLite on every mutation. - Concurrent execution with a configurable per-process cap; jobs can also pin a
SessionTarget::Main(same session) orIsolated(default) session. - Job chaining —
on_success/on_failuretriggers compose jobs into DAGs; cycles are refused at config time. - Failure alerting —
FailureAlertConfig(after N consecutive failures, cooldown, target) plus a globalnotify_on_failure_defaultpolicy that defaults unconfigured jobs to alert the originating channel. - Run history — every run lands in
cron_runswithstarted_at,completed_at,status(Ok/Error/Skipped/Timeout),ErrorReason::Transient|Permanent, and the triggering source (Schedule/Chain/Manual/Catchup). - Template rendering —
tasks::cron::templatesubstitutes variables into job prompts before agent invocation. - Webhook delivery —
tasks::cron::webhook_targetlets a job post its outcome to an arbitrary HTTP endpoint. - Carry-over sweeper —
tasks::cron::carryoverruns a process-global sweep (OnceLock-guarded, no double-spawn across hot-reload) to resume interrupted jobs.
Clock abstraction
The cron service depends on the shared Clock trait (src/tasks/shared/clock.rs):
pub trait Clock: Send + Sync + 'static {
fn now_ms(&self) -> i64;
fn now_utc(&self) -> DateTime<Utc> { /* default from now_ms */ }
}SystemClock is the production implementation; FakeClock (in the testing submodule) provides deterministic time control for unit tests.
RPCs (real implementations)
All cron.* RPCs registered in src/bin/aleph-server/commands/start/builder/handlers/agents.rs are backed by real handlers (src/gateway/handlers/cron/real.rs). The earlier *_stub placeholders that lived in src/gateway/handlers/cron/stubs.rs are no longer reached on the hot path:
| Method | Handler |
|---|---|
cron.list | cron::handle_list |
cron.get | cron::handle_get |
cron.create | cron::handle_create |
cron.update | cron::handle_update |
cron.delete | cron::handle_delete |
cron.status | cron::handle_status |
cron.run | cron::handle_run |
cron.runs | cron::handle_runs |
cron.toggle | cron::handle_toggle |
Heartbeat Probes
The tasks::heartbeat module runs periodic L1 probes with optional L2 agent analysis when a probe fires. The same CronService-style pattern — HeartbeatService facade, HeartbeatServiceState, HeartbeatStore — keeps the two subsystems symmetric.
┌──────────────────────────────────────────────────────────────┐
│ HeartbeatService │
├──────────────────────────────────────────────────────────────┤
│ state: Arc<HeartbeatServiceState> │
│ ├─ store: Arc<Mutex<HeartbeatStore>> │
│ └─ config: HeartbeatConfig (tick interval, max concurrent, │
│ per-job timeout, dedup) │
│ wake_queue: Arc<WakeQueue> │
│ event_bus: Option<Arc<GatewayEventBus>> (HeartbeatTaskChanged)│
└──────────────────────────────────────────────────────────────┘Features
- Probe execution —
tasks::heartbeat::executorruns each task's configuredProbeConfig(tool + params + trigger condition). - Wake queue —
tasks::heartbeat::wakelets an external caller poke a wake signal into the timer loop without blocking on the per-task mutex. - Deduplication —
tasks::heartbeat::dedupcollapses repeated outcomes within aDedupConfig { window_ms, similarity_threshold, max_history }so noisy probes do not flood the model. - Run history —
tasks::heartbeat::historypersists past runs for inspection viaheartbeat.runs. - Configurable intervals and timeouts —
HeartbeatConfig::tick_interval_secs,max_concurrent,job_timeout_secs.
Public API
impl HeartbeatService {
pub fn new(store: HeartbeatStore, config: HeartbeatConfig) -> Self;
pub fn with_event_bus(self, bus: Arc<GatewayEventBus>) -> Self;
pub async fn list_tasks(&self) -> Vec<HeartbeatTaskView>;
pub async fn get_task(&self, id: &str) -> Option<HeartbeatTaskView>;
pub async fn add_task<C: Clock>(&self, task: HeartbeatTask, clock: &C) -> Result<String, String>;
pub async fn update_task<C: Clock>(&self, id: &str, updates: HeartbeatTaskUpdates, clock: &C)
-> Result<(), String>;
pub async fn delete_task(&self, id: &str) -> Result<(), String>;
pub async fn toggle_task<C: Clock>(&self, id: &str, clock: &C) -> Result<bool, String>;
pub async fn reap_history(&self) -> Result<usize, String>;
pub fn request_shutdown(&self);
}RPCs (real implementations)
All heartbeat.* RPCs registered in agents.rs are real handlers (src/gateway/handlers/heartbeat.rs); the older *_stub definitions remain in the file as regression tests but are no longer dispatched by the registry:
| Method | Handler |
|---|---|
heartbeat.list | heartbeat::handle_list |
heartbeat.get | heartbeat::handle_get |
heartbeat.create | heartbeat::handle_create |
heartbeat.update | heartbeat::handle_update |
heartbeat.delete | heartbeat::handle_delete |
heartbeat.toggle | heartbeat::handle_toggle |
heartbeat.wake | heartbeat::handle_wake |
heartbeat.runs | heartbeat::handle_runs |
Task Infrastructure
The tasks::shared module provides cross-cutting infrastructure consumed by both cron and heartbeat:
clock—Clocktrait,SystemClock,FakeClock(intesting).schedule— Pure schedule-kind parsing and next-run computation.delivery—DeliveryEngine/DeliveryPayload/DeliveryTarget(how cron results land in a channel or webhook).store— Reusable SQLite helpers (open, schema migration, in-memory cache).targets— Delivery target type definitions (channels, webhooks).active_hours— Quiet-hours and active-hours gating for jobs.retry_hint— Retry policy primitives (transient vs permanent classification).reaper— Periodically callscron.reap_history/heartbeat.reap_historysocron_runs/ heartbeat history rows do not grow without bound.
Safety Properties
- No underflow — Time arithmetic uses
saturating_sub/saturating_mul(Clock::now_utcusesfrom_timestamp_millisso pre-epoch values round-trip correctly). - No SQL injection — All queries use parameterised
params![]. - UTF-8 safe — String truncation uses
char_indices()-aware helpers. - No
static mut— All shared state goes throughcrate::sync_primitives.
Code Location
Cron:
src/tasks/cron/mod.rs—CronServicefacadesrc/tasks/cron/config.rs—CronConfig,CronJob,ScheduleKind,RunStatus,ErrorReason,SessionTarget,TriggerSource,FailureAlertConfig,JobStateV2,JobRunsrc/tasks/cron/store.rs—CronStore(SQLite + in-memory cache)src/tasks/cron/service/—ServiceState,ops(CRUD),timer(next-due loop),concurrency,catchupsrc/tasks/cron/executor.rs— Job executionsrc/tasks/cron/chain.rs— Job chaining (on_success/on_failure)src/tasks/cron/alert.rs— Failure alertingsrc/tasks/cron/template.rs— Prompt template renderingsrc/tasks/cron/webhook_target.rs— Webhook deliverysrc/tasks/cron/stagger.rs— Hash-based cron staggersrc/tasks/cron/history.rs—cron_runsschema and helperssrc/tasks/cron/carryover.rs— Carry-over sweeper
Heartbeat:
src/tasks/heartbeat/mod.rs—HeartbeatServicefacadesrc/tasks/heartbeat/config.rs—HeartbeatConfig,HeartbeatTask,HeartbeatTaskView,ProbeConfig,TriggerCondition,DedupConfigsrc/tasks/heartbeat/store.rs—HeartbeatStoresrc/tasks/heartbeat/service/—HeartbeatServiceState,ops,timersrc/tasks/heartbeat/executor.rs— Probe executionsrc/tasks/heartbeat/probe.rs— Probe dispatchersrc/tasks/heartbeat/dedup.rs— Outcome deduplicationsrc/tasks/heartbeat/history.rs— Run historysrc/tasks/heartbeat/wake.rs—WakeQueue
Shared:
src/tasks/shared/clock.rs—Clocktrait +SystemClock+FakeClocksrc/tasks/shared/schedule.rs— Pure scheduling computationsrc/tasks/shared/delivery.rs—DeliveryEngineand friendssrc/tasks/shared/store.rs— SQLite helperssrc/tasks/shared/targets.rs— Delivery target typessrc/tasks/shared/active_hours.rs— Active-hours gatingsrc/tasks/shared/retry_hint.rs— Retry classificationsrc/tasks/shared/reaper.rs— History reaper daemon
RPCs:
src/gateway/handlers/cron/real.rs— realcron.*handlerssrc/gateway/handlers/cron/stubs.rs— legacy stubs (kept for regression tests, not dispatched)src/gateway/handlers/heartbeat.rs— realheartbeat.*handlers (the file also contains the legacy*_stubdefinitions as regression tests)src/bin/aleph-server/commands/start/builder/handlers/agents.rs— registerscron.*andheartbeat.*on the gateway server
See Also
- Daemon — Owns the timer loops, day-boundary log rotation,
ALEPH-BOOTmarker, sleep inhibitor, and theGuardian Judgeproactive-wake decisions that drive cron and heartbeat in practice.