Skip to content

Cron Schedule Kinds — Four Ways a Clock Can Wake an Agent

← Back to the README

An interactive explainer for rk cron: the three schedule kinds (every, cron, backoff) and the wake_on edge trigger, each as an animated timeline you can play, restart, and change the rules of — plus the deliver policy that decides what happens when the agent is busy at fire time. Companion to the cron spec.

HexoKit · rk cron
Four ways a clock can wake an agent

An rk cron entry is a small intent file: what text to deliver, which pane to deliver it to, and when. The “when” comes in three schedule kinds plus one optional edge trigger, and a deliver policy on the entry says what to do if the agent is busy at that moment — send anyway, hold until idle, or skip the fire. A ticker polls every 30 seconds and asks a pure function, “given the entries, the delivery log and the panes’ agent states on disk right now, what is due?” Nothing is remembered in memory, so a restart never loses the clock.

schedule fire wake fire missed occurrence held skipped (busy) agent active agent waiting agent idle daemon down

Every panel loads finished. Press Play to watch it happen; toggles change the rules and replay.

every fixed interval

rk cron add "check PRs" --every 5m

A metronome. It fires whenever now − last delivery ≥ interval. The anchor is the newest line for this entry in the delivery log, or the moment the entry was created before its first fire.

What to notice

  • Nothing is “scheduled ahead”. Each poll just re-checks the gap since the last delivery.
  • If the daemon was down, the first poll after restart sees a gap larger than the interval and fires immediately, then the rhythm continues from there. Nothing piles up.

cron wall clock

rk cron add "morning digest" --cron "0 9 * * *" [--catch-up once]

The classic five-field expression, in the daemon’s local time. The evaluator finds the latest occurrence since the last delivery and fires if it is still inside a 2-minute grace window (covering the 30 s poll and short restarts).

What to notice

  • An occurrence that fell while the daemon was down is skipped by default and logged as missed. Firing a 09:00 digest at 14:00 is usually wrong.
  • catch_up: once opts in to exactly one late fire per gap, the moment the daemon is back.

backoff doubling ladder from the last real activity

rk cron add "operator tick" --backoff --min 1m --max 30m

Gaps of 1, 2, 4, 8, 16 min, then 30 min forever, counted from an anchor. The anchor is not the last fire. It is the last time the target pane went idle for a reason other than the clock, read from its @rk_pane_agent_state option.

The signal

  • Every delivery makes the agent busy for a few seconds, then idle again. That flip lands within 120 s of the entry’s own delivery, so the clock says “I caused that” and the ladder keeps climbing.
  • An idle flip not explained by a recent delivery is genuine activity: someone typed, or the agent did real work. The ladder resets to a 1-minute gap from that moment.
  • So: quiet agent, ticks thin out. Busy agent, ticks stay frequent. The agent never has to tell the clock anything.
  • Set min = max and the ladder is flat — “ping every 3 min of quiet”. Same anchor rules: the clock’s own pings don’t restart the count, real activity does. rk cron add "wake up" --idle-every 3m writes exactly this entry — no new kind, no schema field.

wake_on edge trigger, OR’d onto any schedule

wake_on: { event: agent-state-change, scope: server, debounce: 60s }

Not a schedule at all. Each poll fingerprints the state of every other pane on the server and fires when the fingerprint changed by an actionable transition.

Three rules keep it from feeding on itself

  • Actionable = a pane became waiting (a question), became idle (finished), or disappeared. Becoming active is ignored: an agent starting work needs no attention.
  • Self-exclusion: the target’s own pane is left out of the fingerprint. A delivery makes the target busy, and that must never count as the next edge.
  • Debounce: an edge within 60 s of the entry’s last delivery is held, not dropped, and fires on a later poll. A burst coalesces into one delivery.

deliver what happens when the agent is busy at fire time

rk cron add "check PRs" --every 5m --deliver skip-if-busy

The schedule decides when a fire comes due; deliver decides what happens if the target agent is busy at that moment. One every 5m entry below, three policies, one busy stretch (the green block on the state strip).

Hold, drop, or neither

  • immediate ignores the agent state: every boundary fires, busy or not — the payload lands in a working pane.
  • when-idle holds a busy-pane fire and delivers the moment the agent goes idle; the rhythm re-anchors on that delivery. A hold is bounded: 2 hours past the due time it expires with a logged held-expired rather than landing hours late.
  • skip-if-busy drops a busy-pane fire: the skip is logged (skipped-busy), so the next attempt is one full interval later, not the next 30 s poll — and the grid itself never moves.

Put together: the operator’s clock

The operator tick seeded on every tmux server — rk defines the clock; the operator consumer (fab) plants and tunes the entry, and rk operator only launches the operator the entry respawns — is one entry using two of these mechanisms at once. wake_on is the reactive channel: an agent asks a question, the operator is pinged within a poll. backoff is the fallback poll: it thins out to every 24 minutes when nothing is happening and snaps back to 3 minutes the moment someone touches the operator.

Either channel’s fire is a “delivery” of the same entry, appended to a per-server log as {ts, entry, target, reason, outcome}. That log, plus the panes’ state options, is the entire memory of the clock. The only way to silence an entry is to tell it: rk cron mute <id> --for 30m.

id: uqdy
name: operator tick
schedule: { kind: backoff, min: 3m, max: 24m }
wake_on:  { event: agent-state-change, scope: server, debounce: 1m }
target:   { kind: role, role: operator }
payload:  operator tick
deliver:  skip-if-busy
if_absent: respawn   # dead operator? relaunch `rk operator`, then deliver
pinned:   true
KindFires when…Good for
everythe gap since the last delivery reaches the intervalplain heartbeats, polling a queue
crona wall-clock occurrence arrives (missed ones skipped unless catch_up: once)“09:00 every day”, weekly reports
backoffthe doubling ladder from the last genuine idle moment comes dueattention that should fade while quiet
backoff, min = max
(--idle-every)
X after the last genuine idle moment, then every X while it stays quietreminders that wait for quiet
wake_onanother pane finished, asked something, or vanishedreacting within seconds instead of a poll

The flat ladder’s fixed-grid cousin is --every X --deliver skip-if-busy: it fires only at grid points where the agent happens to be idle, skipping busy boundaries instead of waiting out a fresh X of quiet — the deliver panel above shows the difference.

All four are evaluated by the same 30-second poll, so any fire lands up to 30 s after it came due. Every payload must tolerate being delivered twice; a restart may re-fire one due tick.