Engineering

How OpenHelm's Scheduler Works

A look under the hood at OpenHelm's scheduling engine — how jobs are queued, prioritised, and executed.

O
OpenHelm Team· Engineering
··7 min read

OpenHelm's promise — that jobs run on schedule without manual intervention — depends on a reliable scheduling engine. This post explains how that engine actually works.

The Scheduler Loop

At its core, OpenHelm's scheduler is simple: once every 60 seconds, it checks the database for any jobs whose nextFireAt timestamp has arrived.

When a due job is found:

  1. A run record is created with status queued
  2. The run is added to a priority queue
  3. The job's nextFireAt is updated to its next occurrence

That 60-second tick is intentional. Sub-minute scheduling isn't needed for the kind of work OpenHelm is designed for — daily code audits, weekly dependency updates, nightly test runs. The tick keeps the scheduler lightweight and the database activity minimal.

The Priority Queue

Runs don't execute immediately when created — they sit in a priority queue. This queue has three priority levels:

PrioritySourceDescription
0 (highest)ManualTriggered by Run now — always jumps the queue
1ScheduledRegular scheduled fires
2 (lowest)CorrectiveAuto-generated self-correction retries

Manual triggers get the highest priority because when you click "run now", you want it to actually run now — not wait behind a queue of scheduled jobs. Corrective retries get the lowest priority so that normal scheduled work always proceeds even when previous runs are being retried.

Within each priority level, runs execute first-in, first-out.

Concurrency

By default, only one run executes at a time. The concurrency limit is configurable in Settings — you can increase it to 2 or 3 if you want jobs to run in parallel across different projects.

The executor pulls from the priority queue whenever a slot is free. If you're running three concurrent jobs, it fills all three slots simultaneously, always picking from the highest-priority available run.

Schedule Types

Each schedule type has a different rule for calculating the next fire time:

Once: After firing, nextFireAt is set to null — the job never fires again.

Interval: nextFireAt is set to the run's completion time plus the interval. This means runs don't pile up if Claude Code takes longer than expected — the next run starts a full interval after the previous one finishes.

Cron: The next matching cron expression occurrence is calculated from the current time. Standard 5-field cron syntax is supported.

Calendar: A human-friendly alternative to cron. Pick daily, weekly, or monthly with a specific time, and OpenHelm calculates the next occurrence.

Manual: nextFireAt is always null — the scheduler never auto-fires this job.

What Happens on Startup

When OpenHelm launches, it checks for runs that were left in an inconsistent state — for example, if the app was force-quit while a job was running. Any run stuck in running status is automatically marked as failed with a note explaining the reason. Any run stuck in queued status is re-enqueued correctly.

This crash-recovery pass runs before the scheduler starts, so you never start with phantom running jobs.

Job Execution

When the executor picks a run from the queue:

  1. Pre-flight checks run: the job still exists, the project directory exists, the Claude Code binary exists
  2. If any check fails, the run is marked permanent_failure immediately (no retry)
  3. If all checks pass, the run is marked running and Claude Code is launched as a child process
  4. stdout and stderr from Claude Code are streamed in real time to the run log
  5. When the process exits, the run is marked succeeded or failed based on the exit code

Silence Detection

If Claude Code stops producing output for 10 minutes while a run is active, OpenHelm detects the silence and flags it — this usually indicates Claude Code is waiting for interactive input, which it can't receive in headless mode. The run is logged accordingly.

The Result

This architecture keeps things simple and reliable. There's no external scheduler, no separate daemon, no network dependency — just a tick loop, a priority queue, and direct process invocation. When it works, it fades into the background entirely.

More from the blog