Skip to content

Fix cron expression parser, token quota semantics, and dashboard wiring#39

Open
Chocksy wants to merge 3 commits intoRightNow-AI:mainfrom
Chocksy:fix/cron-parser-quota-dashboard
Open

Fix cron expression parser, token quota semantics, and dashboard wiring#39
Chocksy wants to merge 3 commits intoRightNow-AI:mainfrom
Chocksy:fix/cron-parser-quota-dashboard

Conversation

@Chocksy
Copy link

@Chocksy Chocksy commented Feb 27, 2026

Summary

Three bugs that remain unfixed in v0.1.4 (confirmed by independent code review against both tags):

1. Cron expression parser is a placeholder — fires every 60 seconds

compute_next_run() in cron.rs returns Utc::now() + Duration::seconds(60) for ALL CronSchedule::Cron variants. The cron expression (e.g. 0 9 * * 1-5) is completely ignored.

Impact: 5 cron jobs × every 60 seconds = 300 LLM calls/hour. In our deployment, this burned $12.48 in ~4 hours before we caught it. Each call accumulates context (up to 61K prompt tokens per call) making it progressively more expensive.

Fix: Added the cron crate (v0.15) and implemented real parsing with 5-field → 7-field conversion:

CronSchedule::Cron { expr, tz: _ } => {
    let fields: Vec<&str> = expr.trim().split_whitespace().collect();
    let seven_field = match fields.len() {
        5 => format!("0 {} *", expr.trim()),   // standard 5-field
        6 => format!("{} *", expr.trim()),       // 6-field with seconds
        _ => expr.clone(),                       // already 7-field
    };
    match seven_field.parse::<cron::Schedule>() {
        Ok(sched) => sched.upcoming(Utc).next()
            .unwrap_or_else(|| Utc::now() + Duration::hours(1)),
        Err(e) => {
            warn!("Failed to parse cron expression '{}': {}", expr, e);
            Utc::now() + Duration::hours(1)
        }
    }
}

2. Token quota treats 0 as "deny all" instead of unlimited

check_quota() in scheduler.rs does:

if tracker.total_tokens > quota.max_llm_tokens_per_hour { ... }

When max_llm_tokens_per_hour = 0, any usage > 0 triggers quota exceeded. This is inconsistent with the cost quota fields which explicitly document 0.0 = unlimited (see ResourceQuota in agent.rs).

Fix: Added a > 0 guard matching the cost quota convention:

if quota.max_llm_tokens_per_hour > 0
    && tracker.total_tokens > quota.max_llm_tokens_per_hour
{ ... }

3. Dashboard scheduler page calls /api/schedules (KV store) instead of /api/cron/jobs (real engine)

The scheduler page in scheduler.js calls /api/schedules, which reads/writes to the KV memory store — a passive data store that has no connection to the CronScheduler engine. Jobs created through the dashboard UI are never registered with the actual scheduler and never fire.

The real cron jobs live in /api/cron/jobs, backed by the DashMap-based CronScheduler that the kernel tick loop polls via due_jobs().

Fix: Rewired all CRUD operations to use /api/cron/jobs with proper field normalization between the nested API response format and the flat UI model. Also updated the "Next Run" column to show relative future times.

Testing

  • Deployed and verified on a live Hetzner aarch64 server running OpenFang
  • Cron jobs now fire at correct scheduled times (e.g., 0 9 * * 6 fires at Saturday 9:00 AM UTC, not every 60 seconds)
  • Dashboard shows all 5 cron jobs with correct schedules, next run times, and enable/disable controls
  • Overnight monitoring confirmed zero unscheduled LLM calls after the fix

Files Changed

File Change
crates/openfang-kernel/src/cron.rs Real cron parser replacing 60s placeholder
crates/openfang-kernel/Cargo.toml Added cron = "0.15" dependency
crates/openfang-kernel/src/scheduler.rs Token quota > 0 guard
crates/openfang-api/static/js/pages/scheduler.js Rewired to /api/cron/jobs
crates/openfang-api/static/index_body.html "Next Run" column with relative times

Based on v0.1.4 — these are the 3 fixes from PR #17 that weren't included in the v0.1.3 rebuild.

The scheduler's check_quota() would reject all agent turns when
max_llm_tokens_per_hour was set to 0, unlike the metering engine
which correctly skips the cost check when max_cost_per_hour_usd is 0.0.

Add a guard to skip the token check when the limit is 0, making
the behavior consistent with metering.rs.
compute_next_run for Cron schedules was a placeholder that returned
now + 60 seconds regardless of the cron expression. This caused all
cron jobs to fire every minute, burning through token quotas rapidly
(5 jobs × 60/hour = 300 LLM calls/hour).

Added the `cron` crate (0.15) and implemented proper 5-field to 7-field
conversion for standard cron expressions. Jobs now fire at their correct
scheduled times (e.g., "0 9 * * 1-5" fires at 9 AM UTC weekdays only).
The scheduler page was calling /api/schedules (empty KV store) instead
of /api/cron/jobs (the real CronScheduler engine). Jobs created by
agents via cron_create were invisible in the dashboard.

Changed scheduler.js to use /api/cron/jobs for all CRUD operations.
Normalized the nested response format (schedule.expr, action.message)
to flat fields the UI expects. Replaced "Runs" column with "Next Run"
which shows when each job will fire next. Added friendly day-of-week
labels to describeCron() (e.g., "Weekdays at 5:00 AM").
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant