use crate::channels::ChannelFactory;
use crate::config::Config;
use crate::db::CronJobRepository;
use crate::db::CronJobRunRepository;
use crate::db::models::{CronJob, CronJobRun};
use crate::services::{ServiceContext, SessionService};
use crate::utils::string::truncate_str;
use chrono::Utc;
use std::sync::Arc;
use tracing::Instrument;
use uuid::Uuid;
fn is_active_profile(job_profile: Option<&str>, active: Option<&str>) -> bool {
match job_profile {
None => true,
Some(stamped) => stamped == active.unwrap_or("default"),
}
}
static INVALID_EXPR_WARNED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub(crate) const TICK_ALERT_AFTER_FAILURES: u32 = 3;
pub(crate) const TICK_ALERT_MIN_SPACING: std::time::Duration = std::time::Duration::from_secs(3600);
pub(crate) static CRON_SPAWNED_AT: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
pub(crate) static CRON_LAST_TICK_AT: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
pub(crate) const CRON_STALE_AFTER_SECS: u64 = 600;
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum CronLiveness {
Idle,
NotRunningWithJobs { enabled: u64 },
Stalled { age_secs: u64, enabled: u64 },
Healthy { age_secs: u64 },
}
pub(crate) fn cron_liveness(
now: u64,
spawned_at: u64,
last_tick_at: u64,
enabled: u64,
) -> CronLiveness {
if spawned_at == 0 {
return if enabled > 0 {
CronLiveness::NotRunningWithJobs { enabled }
} else {
CronLiveness::Idle
};
}
let age = if last_tick_at == 0 {
now.saturating_sub(spawned_at)
} else {
now.saturating_sub(last_tick_at)
};
if age > CRON_STALE_AFTER_SECS {
CronLiveness::Stalled {
age_secs: age,
enabled,
}
} else {
CronLiveness::Healthy { age_secs: age }
}
}
pub(crate) fn epoch_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(crate) fn alert_due(
failures: u32,
last_alert: Option<std::time::Duration>,
uptime: std::time::Duration,
) -> bool {
if failures < TICK_ALERT_AFTER_FAILURES {
return false;
}
match last_alert {
None => true,
Some(at) => uptime.saturating_sub(at) >= TICK_ALERT_MIN_SPACING,
}
}
pub(crate) fn supervise_tick_result(
joined: Result<Result<(), anyhow::Error>, tokio::task::JoinError>,
) -> Result<(), anyhow::Error> {
match joined {
Ok(result) => result,
Err(e) if e.is_panic() => Err(anyhow::anyhow!(
"cron tick panicked (isolated by supervisor; scheduler keeps running): {e:#}"
)),
Err(e) => Err(anyhow::anyhow!(
"cron tick task cancelled (isolated by supervisor; scheduler keeps running): {e:#}"
)),
}
}
pub struct CronScheduler {
repo: CronJobRepository,
run_repo: CronJobRunRepository,
factory: Arc<ChannelFactory>,
service_context: ServiceContext,
known_targets: std::sync::Mutex<Vec<String>>,
}
impl CronScheduler {
pub fn new(
repo: CronJobRepository,
run_repo: CronJobRunRepository,
factory: Arc<ChannelFactory>,
service_context: ServiceContext,
) -> Self {
Self {
repo,
run_repo,
factory,
service_context,
known_targets: std::sync::Mutex::new(Vec::new()),
}
}
pub fn spawn(self) -> tokio::task::JoinHandle<()> {
tokio::spawn(self.run())
}
pub async fn run(self) {
let me = Arc::new(self);
CRON_SPAWNED_AT.store(epoch_secs(), std::sync::atomic::Ordering::Relaxed);
tracing::info!(
"Cron scheduler started — polling every 60s (shared Cron session, compaction-isolated)"
);
if let Err(e) = me.backfill_missing_next_run().await {
tracing::error!("Failed to backfill missing next_run_at on startup: {e:#}");
}
let started = std::time::Instant::now();
let mut failures: u32 = 0;
let mut last_alert: Option<std::time::Duration> = None;
loop {
let s = Arc::clone(&me);
let outcome = supervise_tick_result(tokio::spawn(async move { s.tick().await }).await);
match outcome {
Ok(()) => {
failures = 0;
last_alert = None;
CRON_LAST_TICK_AT.store(epoch_secs(), std::sync::atomic::Ordering::Relaxed);
}
Err(e) => {
failures = failures.saturating_add(1);
tracing::error!("Cron scheduler tick error: {e:#}");
let uptime = started.elapsed();
if alert_due(failures, last_alert, uptime) {
last_alert = Some(uptime);
me.alert_tick_failure(failures, &e).await;
}
}
}
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
}
}
fn remember_targets(&self, jobs: &[CronJob]) {
let mut targets: Vec<String> = Vec::new();
for job in jobs {
let Some(deliver_to) = job.deliver_to.as_deref() else {
continue;
};
for target in deliver_to
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
{
if !targets.iter().any(|seen| seen == target) {
targets.push(target.to_string());
}
}
}
if let Ok(mut known) = self.known_targets.lock() {
*known = targets;
}
}
async fn alert_tick_failure(&self, failures: u32, err: &anyhow::Error) {
let targets = self
.known_targets
.lock()
.map(|known| known.clone())
.unwrap_or_default();
if targets.is_empty() {
tracing::error!(
"Cron scheduler has failed {failures} consecutive ticks and has no delivery target to alert: no job list has ever been read by this process"
);
return;
}
let message = format!(
"Cron scheduler is stopped: {failures} consecutive tick failures, no scheduled job will fire until this is fixed. Cause: {err:#}"
);
for target in targets {
tracing::error!("Escalating cron scheduler failure to {target}");
let _ = deliver_result(
&target,
"cron scheduler",
&message,
None,
Some(self.service_context.pool()),
)
.await;
}
}
async fn tick(&self) -> anyhow::Result<()> {
let (mut jobs, skipped) = self.repo.list_enabled_with_skips().await?;
self.remember_targets(&jobs);
if jobs.is_empty() && skipped > 0 {
return Err(anyhow::anyhow!(
"all {skipped} enabled cron_jobs row(s) failed to decode, no job can run"
));
}
let now = Utc::now();
for job in &mut jobs {
if job.next_run_at.is_some() {
continue;
}
let Some(next) = super::next_run_utc(&job.cron_expr, job_tz(job), now) else {
continue;
};
let patch = crate::db::repository::CronJobPatch {
next_run_at: Some(Some(next)),
..Default::default()
};
if let Err(e) = self.repo.update_fields(&job.id.to_string(), patch).await {
tracing::warn!(error = %e, job_id = %job.id, "Failed to persist next_run_at in tick");
} else {
job.next_run_at = Some(next);
}
}
for job in &jobs {
if self.is_due(job, now) {
tracing::info!("Cron job '{}' ({}) is due — executing", job.name, job.id);
let next_run = match job.next_run_at {
Some(_) => self.next_run_after(job, now),
None => match super::next_run_utc(&job.cron_expr, job_tz(job), now) {
Some(boundary) => self.next_run_after(job, boundary),
None => None,
},
};
let next_run_str = next_run.map(|dt| dt.to_rfc3339());
if let Err(e) = self
.repo
.update_last_run(&job.id.to_string(), next_run_str.as_deref(), job.run_once)
.await
{
tracing::warn!(
error = %e,
job_id = %job.id,
"Cron tick: failed to advance schedule for '{}' ({}) this tick; \
other due jobs proceed, this one stays due and refires next tick \
(#1917)",
job.name,
job.id
);
continue;
}
let job = job.clone();
let factory = self.factory.clone();
let ctx = self.service_context.clone();
let run_repo = self.run_repo.clone();
let job_name = job.name.clone();
let job_id = job.id;
let report_name = job_name.clone();
let job_task = tokio::spawn(
async move {
let profile = job.profile_name.as_deref();
let active = crate::config::profile::active_profile().unwrap_or("default");
let needs_scope = profile.is_some() && profile != Some(active);
let result = if needs_scope {
crate::config::profile::with_profile_home_async(profile, async {
tracing::info!(
"Cron job '{}' — task-local profile home set to {:?}",
job.name,
crate::config::opencrabs_home()
);
run_cron_job_body(&job, &factory, &ctx, &run_repo).await
})
.await
} else {
run_cron_job_body(&job, &factory, &ctx, &run_repo).await
};
if let Err(e) = result {
tracing::error!("Cron job '{}' failed: {e:#}", job.name);
}
}
.instrument(tracing::info_span!("job", name = %job_name, id = %job_id)),
);
tokio::spawn(async move {
if let Err(e) = job_task.await {
tracing::error!(
"Cron job {report_name} task failed (isolated; scheduler keeps running): {e:#}"
);
}
});
}
}
Ok(())
}
pub async fn backfill_missing_next_run(&self) -> anyhow::Result<usize> {
let jobs = self.repo.list_enabled().await?;
let now = Utc::now();
let mut count = 0;
for job in jobs {
if job.next_run_at.is_none() {
match super::next_run_utc(&job.cron_expr, job_tz(&job), now) {
Some(next) => {
let patch = crate::db::repository::CronJobPatch {
next_run_at: Some(Some(next)),
..Default::default()
};
match self.repo.update_fields(&job.id.to_string(), patch).await {
Ok(true) => {
count += 1;
tracing::info!(
"Backfilled next_run_at for cron job '{}' ({}) -> {}",
job.name,
job.id,
next
);
}
Ok(false) => {}
Err(e) => {
tracing::warn!(
error = %e,
job_id = %job.id,
"Failed to persist backfilled next_run_at"
);
}
}
}
None => {
tracing::warn!(
"Could not compute next run time for job '{}' ({}) with cron '{}'",
job.name,
job.id,
job.cron_expr
);
}
}
}
}
Ok(count)
}
fn is_due(&self, job: &CronJob, now: chrono::DateTime<Utc>) -> bool {
match &job.next_run_at {
Some(next) => *next <= now,
None => {
match super::next_run_utc(&job.cron_expr, job_tz(job), now) {
Some(next) => (next - now).num_seconds() <= 60,
None => {
if !INVALID_EXPR_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
tracing::warn!(
"Invalid cron expression for job '{}': {} (suppressing further warnings until restart)",
job.name,
job.cron_expr
);
}
false
}
}
}
}
}
fn next_run_after(
&self,
job: &CronJob,
after: chrono::DateTime<Utc>,
) -> Option<chrono::DateTime<Utc>> {
super::next_run_utc(&job.cron_expr, job_tz(job), after)
}
}
pub(crate) fn cron_session_title_suffix(job: &CronJob) -> String {
format!("[cron-job:{}]", job.id)
}
pub(crate) async fn resolve_or_create_cron_session(
ctx: &ServiceContext,
job: &CronJob,
) -> anyhow::Result<Uuid> {
let suffix = cron_session_title_suffix(job);
let title = format!("Cron: {} {}", job.name, suffix);
use crate::db::repository::SessionListOptions;
let session_svc = SessionService::new(ctx.clone());
let sessions = session_svc
.list_sessions(SessionListOptions {
include_archived: false,
limit: None,
offset: 0,
query: None,
include_subagents: false,
})
.await?;
if let Some(existing) = sessions
.iter()
.find(|s| s.title.as_deref().is_some_and(|n| n.ends_with(&suffix)))
{
return Ok(existing.id);
}
let config = Config::load()?;
let provider = config.cron.default_provider.clone();
let model = config.cron.default_model.clone();
let session = session_svc
.create_session_with_provider(Some(title), provider, model, None)
.await?;
Ok(session.id)
}
fn job_tz(job: &CronJob) -> chrono_tz::Tz {
super::parse_timezone(&job.timezone).unwrap_or(chrono_tz::UTC)
}
async fn resolve_job_agent(
job: &CronJob,
factory: &ChannelFactory,
ctx: &ServiceContext,
) -> anyhow::Result<(Config, Arc<crate::brain::agent::AgentService>)> {
let current = crate::config::profile::current_profile_name();
if is_active_profile(job.profile_name.as_deref(), Some(¤t)) {
return Ok((Config::load()?, factory.create_agent_service().await));
}
let profile = job.profile_name.as_deref();
tracing::info!(
"Cron job '{}' belongs to profile {:?} (current profile {:?}); \
running under its own profile context",
job.name,
profile,
current
);
let (config, brain, home) = crate::config::profile::with_profile_home(profile, || {
let config = Config::load()?;
let home = crate::config::opencrabs_home();
let brain =
crate::brain::prompt_builder::BrainLoader::new(home.clone()).build_core_brain(None);
anyhow::Ok((config, brain, home))
})?;
let provider = crate::brain::provider::create_provider(&config).await?;
let mut builder = crate::brain::agent::AgentService::new(provider, ctx.clone(), &config)
.await
.with_system_brain(brain)
.with_working_directory(home.clone())
.with_brain_path(home);
if let Some(registry) = factory.tool_registry() {
builder = builder.with_tool_registry(registry);
}
builder = builder.with_headless(true);
Ok((config, Arc::new(builder)))
}
pub(crate) const CRON_RUN_BOUNDARY: &str = "[CONTEXT COMPACTION — Cron job execution boundary]";
async fn open_run_boundary(ctx: &ServiceContext, session_id: Uuid) {
let message_svc = crate::services::MessageService::new(ctx.clone());
if let Err(e) = message_svc
.create_message(
session_id,
"user".to_string(),
CRON_RUN_BOUNDARY.to_string(),
)
.await
{
tracing::warn!("Failed to insert cron compaction marker: {e:#}");
}
}
pub(crate) async fn run_cron_job_body(
job: &CronJob,
factory: &ChannelFactory,
ctx: &ServiceContext,
run_repo: &CronJobRunRepository,
) -> anyhow::Result<()> {
let mut fired_prompt: Option<String> = None;
match crate::cron::PipelineExecutor::evaluate_trigger(job).await {
crate::cron::TriggerOutcome::Skipped(ref trig_res) => {
tracing::info!(
"Cron job '{}' trigger condition not met — skipping execution",
job.name
);
let _ =
crate::cron::PipelineExecutor::record_skipped_run(job, trig_res, run_repo).await;
return Ok(());
}
crate::cron::TriggerOutcome::Error(err) => {
tracing::error!("Cron job '{}' trigger execution error: {err}", job.name);
let run = CronJobRun::new_running(
job.id,
job.name.clone(),
job.provider.clone(),
job.model.clone(),
);
let run_id = run.id.to_string();
let _ = run_repo.insert(&run).await;
let _ = run_repo
.complete_error(&run_id, &format!("Trigger error: {err}"))
.await;
return Ok(());
}
crate::cron::TriggerOutcome::Fired(ref trig_res) => {
tracing::info!(
"Cron job '{}' trigger fired (exit={}, output_bytes={})",
job.name,
trig_res.exit_code,
trig_res.combined_output().len()
);
if job.prompt.trim().is_empty() {
return execute_direct_trigger_job(job, ctx, run_repo, trig_res).await;
}
fired_prompt = Some(crate::cron::interpolate_template(&job.prompt, trig_res));
}
crate::cron::TriggerOutcome::NoTrigger => {}
}
match resolve_or_create_cron_session(ctx, job).await {
Ok(cron_sid) => {
execute_job(
job,
factory,
ctx,
cron_sid,
run_repo,
fired_prompt.as_deref(),
)
.await
}
Err(e) => Err(e),
}
}
async fn execute_job(
job: &CronJob,
factory: &ChannelFactory,
ctx: &ServiceContext,
cron_session_id: Uuid,
run_repo: &CronJobRunRepository,
fired_prompt: Option<&str>,
) -> anyhow::Result<()> {
let (config, agent) = resolve_job_agent(job, factory, ctx).await?;
let effective_provider = job
.provider
.clone()
.or_else(|| config.cron.default_provider.clone());
let effective_model = job
.model
.clone()
.or_else(|| config.cron.default_model.clone());
if let (Some(provider_name), Some(model)) = (&effective_provider, &effective_model) {
match crate::brain::provider::create_provider_by_name(&config, provider_name).await {
Ok(provider) => {
let supported = provider.supported_models();
if !supported.is_empty() && !supported.iter().any(|m| m == model) {
tracing::error!(
"Cron job '{}' — model '{}' is NOT supported by provider '{}' \
(supported: {}). SKIPPING job — fix cron config. \
Either set a valid model or remove the model override to use \
the provider's default ('{}').",
job.name,
model,
provider_name,
supported.join(", "),
provider.default_model(),
);
let run = CronJobRun::new_running(
job.id,
job.name.clone(),
effective_provider.clone(),
effective_model.clone(),
);
let run_id = run.id.to_string();
if let Err(e) = run_repo.insert(&run).await {
tracing::error!("Failed to insert cron run record: {e:#}");
}
let err_msg = format!(
"model '{}' not supported by provider '{}' — cron config invalid",
model, provider_name
);
if let Err(db_err) = run_repo.complete_error(&run_id, &err_msg).await {
tracing::error!("Failed to save cron run error to DB: {db_err}");
}
return Ok(());
}
}
Err(e) => {
tracing::warn!(
"Cron job '{}' — cannot pre-validate model (provider '{}' creation \
failed: {e:#}) — proceeding with default validation",
job.name,
provider_name
);
}
}
}
let run = CronJobRun::new_running(
job.id,
job.name.clone(),
effective_provider.clone(),
effective_model.clone(),
);
let run_id = run.id.to_string();
if let Err(e) = run_repo.insert(&run).await {
tracing::error!("Failed to insert cron run record: {e:#}");
}
let run_started_at = run.started_at.format("%Y-%m-%dT%H:%M:%SZ").to_string();
let session_id = cron_session_id;
tracing::info!(
"Cron job '{}' — using cron session {}",
job.name,
session_id
);
open_run_boundary(ctx, session_id).await;
if let Some(ref provider_name) = effective_provider {
match crate::brain::provider::create_provider_by_name(&config, provider_name).await {
Ok(provider) => {
tracing::info!(
"Cron job '{}' — using provider '{}'",
job.name,
provider_name
);
agent.swap_provider_for_session(
cron_session_id,
provider.clone(),
provider.default_model().to_string(),
);
}
Err(e) => {
tracing::warn!(
"Cron job '{}' — failed to create provider '{}': {e:#}, using system default",
job.name,
provider_name
);
}
}
}
let turn_prompt = fired_prompt.unwrap_or(job.prompt.as_str());
let permitted_targets: Option<Vec<crate::cron::send_scope::PermittedTarget>> =
job.deliver_to.as_deref().map(|targets| {
targets
.split(',')
.map(str::trim)
.filter(|t| !t.is_empty())
.filter_map(|t| {
if let Some(rest) = t.strip_prefix("telegram:") {
parse_telegram_target(rest).map(|(chat_id, _)| {
crate::cron::send_scope::PermittedTarget {
channel: "telegram",
target_id: chat_id.to_string(),
}
})
} else if let Some(rest) = t.strip_prefix("discord:") {
parse_discord_target(rest).map(|(channel_id, _)| {
crate::cron::send_scope::PermittedTarget {
channel: "discord",
target_id: channel_id,
}
})
} else if let Some(rest) = t.strip_prefix("slack:") {
Some(crate::cron::send_scope::PermittedTarget {
channel: "slack",
target_id: rest.to_string(),
})
} else {
t.strip_prefix("whatsapp:").map(|rest| {
crate::cron::send_scope::PermittedTarget {
channel: "whatsapp",
target_id: rest.to_string(),
}
})
}
})
.collect()
});
let (tool_starts, evidence_cb) = tool_start_counter();
let result = crate::cron::send_scope::with_permitted_targets(
permitted_targets,
agent.send_message_with_tools_and_callback(
session_id,
turn_prompt.to_string(),
effective_model,
None, Some(Arc::new(|_| {
Box::pin(async { Ok((true, false)) })
})),
Some(evidence_cb),
"cron",
None,
None,
),
)
.await;
match result {
Ok(response) => {
let clean = crate::utils::sanitize::strip_llm_artifacts(&response.content);
let starts = tool_starts.load(std::sync::atomic::Ordering::Relaxed);
tracing::info!(
"Cron job '{}' completed — {} tokens, ${:.6}, {} tool start(s)",
job.name,
response.usage.input_tokens + response.usage.output_tokens,
response.cost,
starts
);
let at = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let deliver = match classify_turn_outcome(starts, &job.name, &run_id, &at) {
TurnOutcome::Success => {
if let Err(e) = run_repo
.complete_success(
&run_id,
&clean,
response.usage.input_tokens as i64,
response.usage.output_tokens as i64,
response.cost,
)
.await
{
tracing::error!("Failed to save cron run result to DB: {e:#}");
}
stamp_delivery(&clean, &run_id, &run_started_at)
}
TurnOutcome::NoOp(notice) => {
tracing::warn!(
"Cron job '{}' executed no tools this run, recorded as no_op, report suppressed (#1703)",
job.name
);
if let Err(e) = run_repo
.complete_no_op(
&run_id,
&clean,
response.usage.input_tokens as i64,
response.usage.output_tokens as i64,
response.cost,
)
.await
{
tracing::error!("Failed to save cron no-op run to DB: {e:#}");
}
notice
}
};
if let Some(ref deliver_to) = job.deliver_to {
for target in deliver_to
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
{
let _ = deliver_result(
target,
&job.name,
&deliver,
job.deliver_api_key.as_deref(),
Some(ctx.pool()),
)
.await;
}
}
let _ = crate::cron::PipelineExecutor::maybe_dispatch_goal(job, ctx, &deliver).await;
}
Err(e) => {
tracing::error!("Cron job '{}' agent error: {e:#}", job.name);
let error_msg = format!("{e}");
if let Err(db_err) = run_repo.complete_error(&run_id, &error_msg).await {
tracing::error!("Failed to save cron run error to DB: {db_err}");
}
if let Some(ref deliver_to) = job.deliver_to {
let msg = stamp_delivery(
&format!("Cron job '{}' failed: {e}", job.name),
&run_id,
&run_started_at,
);
for target in deliver_to
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
{
let _ = deliver_result(
target,
&job.name,
&msg,
job.deliver_api_key.as_deref(),
Some(ctx.pool()),
)
.await;
}
}
}
}
Ok(())
}
pub(crate) fn parse_telegram_target(target: &str) -> Option<(i64, Option<i64>)> {
let mut parts = target.split(':');
let chat_id = parts.next()?.trim().parse::<i64>().ok()?;
match parts.next() {
None => Some((chat_id, None)),
Some(thread) => {
let thread_id = thread.trim().parse::<i64>().ok()?;
if parts.next().is_some() {
return None;
}
Some((chat_id, Some(thread_id)))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DiscordDelivery {
Channel,
Forum,
}
pub(crate) fn parse_discord_target(target: &str) -> Option<(String, DiscordDelivery)> {
let mut parts = target.split(':');
let id = parts.next()?.trim();
if id.is_empty() || !id.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
match parts.next() {
None => Some((id.to_string(), DiscordDelivery::Channel)),
Some(mode) => {
if parts.next().is_some() {
return None;
}
if mode.trim().eq_ignore_ascii_case("forum") {
Some((id.to_string(), DiscordDelivery::Forum))
} else {
None
}
}
}
}
const CHANNEL_FLAG_REQUIRE_TAG: u64 = 1 << 4;
pub(crate) fn forum_requires_tag(channel: &serde_json::Value) -> bool {
channel
.get("flags")
.and_then(serde_json::Value::as_u64)
.is_some_and(|flags| flags & CHANNEL_FLAG_REQUIRE_TAG != 0)
}
pub(crate) fn forum_tag_names(channel: &serde_json::Value) -> Vec<String> {
channel
.get("available_tags")
.and_then(serde_json::Value::as_array)
.map(|tags| {
tags.iter()
.filter_map(|tag| tag.get("name").and_then(serde_json::Value::as_str))
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
pub(crate) fn is_thread_only_channel(kind: u64) -> bool {
matches!(kind, 15 | 16)
}
const FORUM_POST_NAME_MAX: usize = 100;
pub(crate) fn forum_post_title(job_name: &str, when: chrono::DateTime<Utc>) -> String {
let stamp = when.format("%Y-%m-%d %H:%M UTC").to_string();
let joiner = " | ";
let budget = FORUM_POST_NAME_MAX.saturating_sub(stamp.len() + joiner.len());
let head: String = job_name.trim().chars().take(budget).collect();
let head = head.trim_end();
if head.chars().count() < 2 {
return stamp;
}
format!("{head}{joiner}{stamp}")
}
pub(crate) fn forum_post_body(name: &str, content: &str) -> serde_json::Value {
serde_json::json!({
"name": name,
"message": { "content": content },
})
}
pub(crate) async fn parse_session_target(target: &str) -> Option<Uuid> {
if let Ok(uuid) = Uuid::parse_str(target) {
return Some(uuid);
}
let config = crate::config::Config::load().ok()?;
let db = crate::db::Database::connect(&config.database.path)
.await
.ok()?;
let sessions = crate::db::repository::SessionRepository::new(db.pool().clone())
.list(crate::db::repository::SessionListOptions::default())
.await
.ok()?;
resolve_session_target(&sessions, target)
}
pub(crate) fn resolve_session_target(
sessions: &[crate::db::models::Session],
target: &str,
) -> Option<Uuid> {
crate::cli::session_resolve::resolve_session_id(sessions, target).ok()
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum TurnOutcome {
Success,
NoOp(String),
}
pub(crate) fn classify_turn_outcome(
starts: usize,
job_name: &str,
run_id: &str,
at: &str,
) -> TurnOutcome {
if starts == 0 {
TurnOutcome::NoOp(not_executed_notice(job_name, run_id, at))
} else {
TurnOutcome::Success
}
}
pub(crate) fn not_executed_notice(job_name: &str, run_id: &str, at: &str) -> String {
format!(
"Cron job '{job_name}' not executed: no tool ran in this run ({at}, run {run_id}). The report was suppressed."
)
}
pub(crate) fn run_stamp(run_id: &str, started_at: &str) -> String {
let short = run_id.get(..8).unwrap_or(run_id);
format!("[run {short}, started {started_at}]")
}
pub(crate) fn stamp_delivery(content: &str, run_id: &str, started_at: &str) -> String {
let stamp = run_stamp(run_id, started_at);
if content.contains(&stamp) {
content.to_string()
} else {
format!("{content}\n\n{stamp}")
}
}
pub(crate) fn tool_start_counter() -> (
Arc<std::sync::atomic::AtomicUsize>,
crate::brain::agent::ProgressCallback,
) {
use crate::brain::agent::{ProgressCallback, ProgressEvent};
use std::sync::atomic::{AtomicUsize, Ordering};
let starts = Arc::new(AtomicUsize::new(0));
let cb: ProgressCallback = {
let starts = starts.clone();
Arc::new(move |_sid: Uuid, event: ProgressEvent| {
if matches!(event, ProgressEvent::ToolStarted { .. }) {
starts.fetch_add(1, Ordering::Relaxed);
}
})
};
(starts, cb)
}
pub(crate) fn truncate_for_delivery(content: &str) -> String {
const MAX_LEN: usize = 4000;
if content.len() > MAX_LEN {
format!(
"{}...\n\n(truncated — full output in session)",
truncate_str(content, MAX_LEN)
)
} else {
content.to_string()
}
}
pub(crate) async fn deliver_result(
deliver_to: &str,
job_name: &str,
content: &str,
api_key: Option<&str>,
pool: Option<crate::db::Pool>,
) -> Option<tokio::task::JoinHandle<()>> {
#[cfg(not(feature = "telegram"))]
let _ = &pool;
if deliver_to.starts_with("http://") || deliver_to.starts_with("https://") {
deliver_http(deliver_to, job_name, content, api_key).await;
return None;
}
if crate::channels::target_resolver::is_target_url(deliver_to) {
tracing::error!(
"Unbaked target URL '{deliver_to}' reached delivery for job '{job_name}' — oc:// targets must be baked at create/update time (#148); refusing fire-time resolution"
);
return None;
}
let parts: Vec<&str> = deliver_to.splitn(2, ':').collect();
if parts.len() != 2 {
tracing::warn!(
"Invalid deliver_to format '{}' for job '{}' — expected 'channel:id' or HTTP URL",
deliver_to,
job_name
);
return None;
}
let (channel, target_id) = (parts[0], parts[1]);
let msg = truncate_for_delivery(content);
let delivery_msg = format!("⏰ **Cron: {job_name}**\n\n{msg}");
match channel {
"session" => {
let Some(session_id) = parse_session_target(target_id).await else {
tracing::error!(
"Invalid session deliver_to target '{target_id}' for job '{job_name}' \
— no session matches; not delivering"
);
return None;
};
tracing::info!("Delivering cron result to session {session_id} (mode turn-end)");
let queued = crate::brain::agent::service::QueuedUserMessage {
context_text: delivery_msg.clone(),
display_text: delivery_msg,
origin: crate::brain::agent::PushOrigin::SessionNotify,
bg_meta: None,
};
let delivery = crate::brain::agent::service::session_routes::deliver_to_session(
session_id, queued, false,
);
tracing::info!("Cron '{job_name}' session delivery verdict: {delivery:?}");
return None;
}
"telegram" => {
#[cfg(feature = "telegram")]
{
match parse_telegram_target(target_id) {
Some((cid, thread_id)) => {
tracing::info!(
"Delivering cron result to Telegram chat {cid}{}",
thread_id
.map(|t| format!(" thread {t}"))
.unwrap_or_default()
);
return deliver_telegram(
cid,
thread_id,
job_name,
&delivery_msg,
pool.clone(),
)
.await;
}
None => {
tracing::error!(
"Invalid Telegram deliver_to target '{target_id}' for job \
'{job_name}' — expected 'telegram:<chat_id>' or \
'telegram:<chat_id>:<thread_id>'; not delivering"
);
}
}
}
#[cfg(not(feature = "telegram"))]
{
tracing::warn!("Telegram feature not enabled — cannot deliver cron result");
}
}
"discord" => {
#[cfg(feature = "discord")]
{
match parse_discord_target(target_id) {
Some((channel_id, DiscordDelivery::Channel)) => {
tracing::info!("Delivering cron result to Discord channel {channel_id}");
deliver_discord(&channel_id, &delivery_msg).await;
}
Some((channel_id, DiscordDelivery::Forum)) => {
tracing::info!(
"Delivering cron result as one forum post in Discord channel {channel_id}"
);
deliver_discord_forum(&channel_id, job_name, &delivery_msg).await;
}
None => {
tracing::error!(
"Invalid Discord deliver_to target '{target_id}' for job '{job_name}' \
(expected 'discord:<channel_id>' or 'discord:<channel_id>:forum'); \
not delivering"
);
}
}
}
#[cfg(not(feature = "discord"))]
{
tracing::warn!("Discord feature not enabled — cannot deliver cron result");
}
}
"slack" => {
#[cfg(feature = "slack")]
{
tracing::info!("Delivering cron result to Slack channel {target_id}");
deliver_slack(target_id, &delivery_msg).await;
}
#[cfg(not(feature = "slack"))]
{
tracing::warn!("Slack feature not enabled — cannot deliver cron result");
}
}
other => {
tracing::warn!("Unknown delivery channel '{other}' for job '{job_name}'");
}
}
None
}
async fn execute_direct_trigger_job(
job: &CronJob,
ctx: &ServiceContext,
run_repo: &CronJobRunRepository,
trig_res: &crate::cron::TriggerResult,
) -> anyhow::Result<()> {
let run = CronJobRun::new_running(
job.id,
job.name.clone(),
job.provider.clone(),
job.model.clone(),
);
let run_id = run.id.to_string();
if let Err(e) = run_repo.insert(&run).await {
tracing::error!("Failed to insert cron run record: {e:#}");
}
let raw_output = trig_res.combined_output();
let content = if let Some(ref tmpl) = job.goal_template {
crate::cron::interpolate_template(tmpl, trig_res)
} else {
raw_output
};
let clean = crate::utils::sanitize::strip_llm_artifacts(&content);
tracing::info!(
"Cron job '{}' direct trigger completed — 0 tokens, $0.00",
job.name
);
if let Err(e) = run_repo.complete_success(&run_id, &clean, 0, 0, 0.0).await {
tracing::error!("Failed to save direct cron run result to DB: {e:#}");
}
if let Some(ref deliver_to) = job.deliver_to {
for target in deliver_to
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
{
let _ = deliver_result(
target,
&job.name,
&clean,
job.deliver_api_key.as_deref(),
Some(ctx.pool()),
)
.await;
}
}
let _ = crate::cron::PipelineExecutor::maybe_dispatch_goal(job, ctx, &clean).await;
Ok(())
}
async fn deliver_http(url: &str, job_name: &str, content: &str, api_key: Option<&str>) {
let client = reqwest::Client::new();
let body = serde_json::json!({
"job_name": job_name,
"content": content,
"timestamp": chrono::Utc::now().to_rfc3339(),
});
let mut request = client.post(url).json(&body);
if let Some(key) = api_key {
request = request.header("Authorization", format!("Bearer {key}"));
}
match request.send().await {
Ok(resp) if resp.status().is_success() => {
tracing::info!("Cron result for '{job_name}' delivered to {url}");
}
Ok(resp) => {
tracing::warn!(
"HTTP delivery to {url} failed ({}): {:?}",
resp.status(),
resp.text().await.unwrap_or_default()
);
}
Err(e) => {
tracing::error!("HTTP delivery to {url} error: {e:#}");
}
}
}
#[cfg(any(feature = "telegram", feature = "discord", feature = "slack"))]
fn read_channel_secret(channel: &str, field: &str) -> Option<String> {
let keys_path = crate::brain::BrainLoader::resolve_path().join("keys.toml");
let content = std::fs::read_to_string(&keys_path).ok()?;
content.parse::<toml::Table>().ok().and_then(|t| {
t.get("channels")?
.as_table()?
.get(channel)?
.as_table()?
.get(field)?
.as_str()
.map(String::from)
})
}
#[cfg(feature = "slack")]
fn split_for_delivery(text: &str, max_len: usize) -> Vec<&str> {
if text.len() <= max_len {
return vec![text];
}
let mut chunks = Vec::new();
let mut start = 0;
while start < text.len() {
let mut end = (start + max_len).min(text.len());
while end < text.len() && !text.is_char_boundary(end) {
end -= 1;
}
let break_at = if end < text.len() {
text[start..end]
.rfind('\n')
.filter(|&pos| pos > end - start - 200)
.map(|pos| start + pos + 1)
.unwrap_or(end)
} else {
end
};
chunks.push(&text[start..break_at]);
start = break_at;
}
chunks
}
#[cfg(feature = "discord")]
pub(crate) fn discord_chunks_for_delivery(message: &str) -> Vec<String> {
let normalized = crate::channels::discord::table_convert::tables_to_discord(message);
crate::channels::discord::handler::split_message(&normalized, 2000)
}
#[cfg(feature = "slack")]
pub(crate) fn slack_chunks_for_delivery(message: &str) -> Vec<String> {
let normalized = crate::channels::slack::table_convert::structure_to_slack(message);
split_for_delivery(&normalized, 3500)
.into_iter()
.map(str::to_string)
.collect()
}
#[cfg(feature = "telegram")]
async fn deliver_telegram(
chat_id: i64,
thread_id: Option<i64>,
job_name: &str,
message: &str,
pool: Option<crate::db::Pool>,
) -> Option<tokio::task::JoinHandle<()>> {
let Some(token) = read_channel_secret("telegram", "token") else {
tracing::warn!("No Telegram bot token found in keys.toml — cannot deliver cron result");
return None;
};
let bot = teloxide::Bot::new(token);
use teloxide::prelude::Requester;
let thread = thread_id.map(|t| teloxide::types::ThreadId(teloxide::types::MessageId(t as i32)));
if let Some(tid) = thread_id {
match bot.get_chat(teloxide::types::ChatId(chat_id)).await {
Ok(chat) if is_forum_chat(&chat) => {}
Ok(_) => {
tracing::error!(
"Cron job '{job_name}': deliver_to thread {tid} rejected — chat {chat_id} \
is not a forum (topics disabled); refusing delivery instead of falling \
back to the default topic"
);
return None;
}
Err(e) => {
tracing::error!(
"Cron job '{job_name}': cannot validate chat {chat_id} for thread \
{tid} delivery: {e:#} — refusing delivery"
);
return None;
}
}
}
let message = message.to_string();
let job_name = job_name.to_string();
Some(tokio::spawn(async move {
match crate::channels::telegram::send::send_markdown_outbox(
&bot,
teloxide::types::ChatId(chat_id),
thread,
&message,
"cron",
&job_name,
None,
)
.await
{
Ok(outbox) => {
tracing::info!(
"Cron result for '{job_name}' delivered to Telegram chat {chat_id}{} ({} part(s))",
outbox
.effective_thread_id
.map(|t| format!(" thread {}", t.0.0))
.unwrap_or_default(),
outbox.sent.len()
);
outbox.record_outgoing(pool, chat_id).await;
}
Err(e) => {
if let Some(t) = thread_id {
tracing::error!(
"Cron delivery for '{job_name}' to chat {chat_id} thread {t} failed: {e:#} — \
if the error is 'message thread not found', topic {t} does not exist \
in chat {chat_id}; fix the job's deliver_to (there is no fallback to the \
default topic)"
);
} else {
tracing::error!(
"Cron delivery for '{job_name}' to chat {chat_id} failed: {e:#}"
);
}
}
}
}))
}
#[cfg(feature = "telegram")]
fn is_forum_chat(chat: &teloxide::types::ChatFullInfo) -> bool {
matches!(
&chat.kind,
teloxide::types::ChatFullInfoKind::Public(public)
if matches!(
&public.kind,
teloxide::types::ChatFullInfoPublicKind::Supergroup(supergroup)
if supergroup.is_forum
)
)
}
#[cfg(feature = "discord")]
async fn deliver_discord(channel_id: &str, message: &str) {
let Some(token) = read_channel_secret("discord", "token") else {
tracing::warn!("No Discord bot token found in keys.toml — cannot deliver cron result");
return;
};
let url = format!("https://discord.com/api/v10/channels/{channel_id}/messages");
let client = reqwest::Client::new();
let mut delivered = 0usize;
for chunk in discord_chunks_for_delivery(message) {
let body = serde_json::json!({ "content": chunk });
match client
.post(&url)
.header("Authorization", format!("Bot {token}"))
.json(&body)
.send()
.await
{
Ok(resp) if resp.status().is_success() => delivered += 1,
Ok(resp) => {
tracing::warn!(
"Discord delivery to {channel_id} failed ({}): {:?}",
resp.status(),
resp.text().await.unwrap_or_default()
);
}
Err(e) => {
tracing::error!("Discord delivery to {channel_id} HTTP error: {e:#}");
}
}
}
if delivered > 0 {
tracing::info!(
"Cron result delivered to Discord channel {channel_id} ({delivered} part(s))"
);
}
}
#[cfg(feature = "discord")]
async fn deliver_discord_forum(forum_id: &str, job_name: &str, message: &str) {
let Some(token) = read_channel_secret("discord", "token") else {
tracing::warn!("No Discord bot token found in keys.toml; cannot deliver cron result");
return;
};
let client = reqwest::Client::new();
let base = "https://discord.com/api/v10";
let auth = format!("Bot {token}");
let channel: serde_json::Value = match client
.get(format!("{base}/channels/{forum_id}"))
.header("Authorization", &auth)
.send()
.await
{
Ok(resp) if resp.status().is_success() => match resp.json().await {
Ok(json) => json,
Err(e) => {
tracing::error!(
"Discord forum delivery: unreadable channel object for {forum_id}: {e:#}"
);
return;
}
},
Ok(resp) => {
tracing::error!(
"Discord forum delivery: cannot read channel {forum_id} ({}): {:?}",
resp.status(),
resp.text().await.unwrap_or_default()
);
return;
}
Err(e) => {
tracing::error!("Discord forum delivery: HTTP error reading channel {forum_id}: {e:#}");
return;
}
};
let Some(kind) = channel.get("type").and_then(serde_json::Value::as_u64) else {
tracing::error!(
"Discord forum delivery: channel {forum_id} has no type field; not posting"
);
return;
};
if !is_thread_only_channel(kind) {
tracing::error!(
"Discord forum delivery: channel {forum_id} is type {kind}, not GUILD_FORUM (15) or \
GUILD_MEDIA (16): 'discord:{forum_id}:forum' has to name a forum channel; not posting"
);
return;
}
if forum_requires_tag(&channel) {
tracing::error!(
"Discord forum delivery: channel {forum_id} sets REQUIRE_TAG, so every post needs \
applied_tags and this build sends none (the #1851 policy). Tags on offer: [{}]. \
Point the job at a tagless forum, or land the configured-tag option; not posting",
forum_tag_names(&channel).join(", ")
);
return;
}
let title = forum_post_title(job_name, Utc::now());
let chunks = discord_chunks_for_delivery(message);
let first = chunks.first().map(String::as_str).unwrap_or("");
let mut posted = 0usize;
let thread_id = match client
.post(format!("{base}/channels/{forum_id}/threads"))
.header("Authorization", &auth)
.json(&forum_post_body(&title, first))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
posted += 1;
resp.json().await.ok().and_then(|json: serde_json::Value| {
json.get("id")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
}
Ok(resp) => {
tracing::error!(
"Discord forum delivery to {forum_id} failed ({}): {:?}",
resp.status(),
resp.text().await.unwrap_or_default()
);
None
}
Err(e) => {
tracing::error!("Discord forum delivery to {forum_id} HTTP error: {e:#}");
None
}
};
let Some(thread_id) = thread_id else {
if posted == 0 {
tracing::error!(
"Discord forum delivery: no post created in {forum_id}; report not delivered"
);
}
return;
};
for chunk in chunks.iter().skip(1) {
let body = serde_json::json!({ "content": chunk });
match client
.post(format!("{base}/channels/{thread_id}/messages"))
.header("Authorization", &auth)
.json(&body)
.send()
.await
{
Ok(resp) if resp.status().is_success() => posted += 1,
Ok(resp) => {
tracing::warn!(
"Discord forum follow-up to thread {thread_id} failed ({}): {:?}",
resp.status(),
resp.text().await.unwrap_or_default()
);
}
Err(e) => {
tracing::error!("Discord forum follow-up to thread {thread_id} HTTP error: {e:#}");
}
}
}
tracing::info!(
"Cron result delivered as forum post '{title}' in channel {forum_id} \
({posted} message(s), thread {thread_id})"
);
}
#[cfg(feature = "slack")]
async fn deliver_slack(channel_id: &str, message: &str) {
let Some(token) = read_channel_secret("slack", "token") else {
tracing::warn!("No Slack bot token found in keys.toml — cannot deliver cron result");
return;
};
let url = "https://slack.com/api/chat.postMessage";
let client = reqwest::Client::new();
let mut delivered = 0usize;
for chunk in slack_chunks_for_delivery(message) {
let body = serde_json::json!({ "channel": channel_id, "text": chunk });
match client
.post(url)
.header("Authorization", format!("Bearer {token}"))
.json(&body)
.send()
.await
{
Ok(resp) => {
let parsed: serde_json::Value = resp.json().await.unwrap_or_default();
if parsed.get("ok").and_then(serde_json::Value::as_bool) == Some(true) {
delivered += 1;
} else {
tracing::warn!(
"Slack delivery to {channel_id} failed: {}",
parsed
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("unknown error")
);
}
}
Err(e) => {
tracing::error!("Slack delivery to {channel_id} HTTP error: {e:#}");
}
}
}
if delivered > 0 {
tracing::info!("Cron result delivered to Slack channel {channel_id} ({delivered} part(s))");
}
}