s14: Cron Scheduler — Producing Work on a Schedule
s01 → ... → s12 → s13 → s14 → s15 → s16 → ... → s20
"Produce work on a schedule, decouple scheduling from execution" — Cron scheduling, durable or session-level.
Harness Layer: Scheduling — Independent thread checks time, queue delivers triggers.
The Problem
An alarm clock doesn't need you to watch it. You set 7:00, it rings at 7:00 — you could be sleeping, showering, cooking, it rings regardless.
s13 lets the agent run slow operations in the background, but every operation is still triggered manually. You say something, the agent acts. "Run tests every morning at 9am", "Check CI status every 30 minutes" — these recurring tasks shouldn't need a human to push them each time.
The Solution
)
Teaching code carries forward S13's simplified task system, background execution, and prompt assembly; to stay focused on the scheduler, it omits full error recovery, memory, and skill systems. Added: an independent cron scheduler thread that polls every second, queues matching jobs into cron_queue, and a queue processor that delivers them when the agent is idle.
Manual vs Scheduled:
How It Works
Four-Layer Model
Cron scheduling has four layers:
- Scheduler: daemon thread, polls every second, checks if it's time
- Queue:
cron_queue, scheduler writes fired jobs - Queue Processor: sees non-empty queue and idle agent, starts one agent_loop turn
- Consumer: agent_loop consumes queue and injects into messages
The teaching version implements a minimal queue processor: agent_lock tells whether the agent is idle, and queued cron work is delivered automatically. Real CC's useQueueProcessor.ts also handles UI blocking, queue priority, and different message modes.
CronJob: Data Structure
Each cron task is a CronJob object:
Cron expression, 5 fields, used by Unix for 50 years:
Supports *, */N, N, N-M, N,M,....
cron_matches: 5-Field Matching
Standard cron semantics: minute, hour, month must all match; day-of-month (DOM) and day-of-week (DOW) use OR when both are constrained:
Independent Scheduler Thread: 1-Second Polling
The scheduler runs in an independent daemon thread, not dependent on whether agent_loop is executing. Individual job errors don't kill the entire thread:
Key design:
- Independent of agent_loop: scheduler checks time in background even when agent_loop isn't running
- Date-aware minute_marker: uses
"YYYY-MM-DD HH:MM"to prevent same-minute double-fire while not skipping on the next day - Per-job try/except: one bad job doesn't crash the scheduler thread
- One-shot jobs: auto-removed from scheduled_jobs after firing
Queue Processor + agent_loop: Delivery
The queue processor does not check time. It only starts a turn when queued work exists and the agent is idle:
agent_loop also doesn't check time. It only takes fired tasks from cron_queue and injects them into messages:
Producer (scheduler thread), deliverer (queue processor), and consumer (agent_loop) are decoupled via cron_queue, cron_lock, and agent_lock.
Validation: Prevent Bad Cron from Killing the Scheduler
schedule_job validates the cron expression before registering, returning an error for invalid input:
Loading durable jobs from disk also skips invalid expressions, preventing a single bad task from breaking startup.
Durable vs Session-only
- Durable: Task definition written to
.scheduled_tasks.json. Loaded on agent restart. - Session-only: In-memory only. Gone when the agent closes.
Important caveat: The cron scheduler must run inside the agent process. Process exits, scheduler stops. Durable only means the task definition survives restarts — next time the agent starts, the scheduler discovers "it should fire" and fires. If you need "run even when the app is closed", use system crontab or systemd timer.
Putting It Together
Changes from s13
Try It
Try these prompts:
Schedule a task to print the current date every 2 minutesList all cron jobsCreate a one-shot reminder in 1 minute to check the build statusCancel the recurring job and verify with list_crons
What to observe: Is the scheduler thread running independently? Do cron tasks fire at the correct time? Without a new prompt, do you see [queue processor] and automatic execution? Is the durable job written to .scheduled_tasks.json?
What's Next
One agent can do a lot now: plan, compress, background, schedule. But some tasks are too big for one agent.
"Refactor the entire backend" — overhaul auth, database layer, API routes, and tests. One agent's attention is limited. This needs a team.
s15 Agent Teams → One agent isn't enough, form a team. Persistent teammates + async inboxes.
Deep Dive into CC Source
The following is a complete analysis based on CC source code
CronCreateTool.ts,cronScheduler.ts,cron.ts,cronTasks.ts,cronTasksLock.ts,useScheduledTasks.ts(139 lines).
1. Three Cron Tools
CC exposes three cron tools to the model: CronCreate, CronDelete, CronList. All controlled by compile-time gate feature('AGENT_TRIGGERS') and runtime GrowthBook flag tengu_kairos_cron. There's also a CLAUDE_CODE_DISABLE_CRON env var for local override.
2. Storage: .claude/scheduled_tasks.json
Durable tasks write to disk; session-only tasks live in STATE.sessionCronTasks memory array (lost on process restart). A .scheduled_tasks.lock file prevents duplicate firing across multiple sessions of the same project.
3. Scheduler: 1-Second Polling
cronScheduler.ts checks every second (CHECK_INTERVAL_MS = 1000). Whoever holds the lock triggers file tasks; all sessions trigger session-only tasks. A chokidar file watcher monitors scheduled_tasks.json changes.
4. Cron Expression: Standard 5 Fields
Minute hour day month weekday. Supports *, */N, N, N-M, N-M/S, N,M,.... Doesn't support L, W, ?. All times interpreted in local timezone. Day-of-month and day-of-week use OR semantics when both are constrained.
5. Jitter (Thundering Herd Prevention)
- Recurring tasks: trigger delay up to 10% of period (max 15 min), deterministic hash based on task ID
- One-shot tasks: up to 90s early when firing time falls on
:00or:30 - Jitter config adjustable via GrowthBook, refreshed every 60 seconds
6. Auto-Expiration
Recurring tasks auto-expire after 7 days (configurable, max 30 days). Fire one last time before expiry, then auto-delete.
7. Job Limit
MAX_JOBS = 50 (CronCreateTool.ts:25). Returns error when exceeded: "Too many scheduled jobs (max 50). Cancel one first."
8. Trigger Injection
After firing, enqueued via enqueuePendingNotification() with priority: 'later' into the command queue. Tagged workload: WORKLOAD_CRON — API serves cron-initiated requests at lower QoS when capacity is tight.
9. Queue Processor: Automatic Delivery
Real CC auto-triggers processing through useQueueProcessor.ts:48-60 when no query is active, UI isn't blocked, and queue is non-empty. queueProcessor.ts:52-87 dispatches commands to handlePromptSubmit() by queue priority. The teaching version keeps the core behavior with queue_processor_loop: when queued work exists and the agent is idle, it starts one agent_loop turn automatically.
