Automations scheduling
How Coven turns a routine's schedule into due slots, including time zones, daylight saving, missed slots after downtime, overlap, leases, retries, quarantine, timeouts, and cancellation, with the conformance cases that pin each rule.
6 min read
This page covers how the daemon decides when a routine runs and what happens when time, downtime, or a failure gets in the way. For the routine fields and actions, see Coven automations; for the state names used below, see Automations lifecycle.
The tables are tested cases
Each table renders a byte-for-byte copy of an upstream conformance vector file
at the reviewed commit. Upstream CI runs every one of these files through the
daemon's own scheduling code with coven automations conformance evaluate,
and its Rust tests require every case to pass. The conformance target refuses
a vector file whose cases differ from its built-in scenarios, so these are the
inputs CI executes. The target uses temporary stores, a pinned clock, and a
stand-in runtime rather than a live daemon. Passing them is audit evidence,
not certification; see Automations conformance.
Schedules
A routine's rrule uses a small subset of iCalendar recurrence rules:
FREQis required and isDAILYorWEEKLY.BYHOURlists hours from0to23. Each slot starts on the hour. WithoutBYHOUR, a routine runs at9.BYDAYlists weekdays asMOtoSUorMONtoSUN, and only withFREQ=WEEKLY. WithoutBYDAY, a weekly routine runs on Monday.- Keys and values are case-insensitive.
Nothing else is accepted: no other frequency, and no INTERVAL, COUNT,
UNTIL, BYMINUTE, or other key. A repeated key, a repeated hour or weekday
(including MO with MON), and an empty part or value are refused too. An
unsupported rule fails validation rather than being approximated.
22 cases from rrule-vocabulary.vectors.json:
| Case | Rule as written | Result |
|---|---|---|
| daily-defaults | FREQ=DAILY | accepted as daily, hours 9 |
| daily-hour-normalization | freq=daily;byhour=17,9 | accepted as daily, hours 9, 17 |
| weekly-defaults | FREQ=WEEKLY | accepted as weekly, hours 9, days MO |
| weekly-day-normalization | freq=weekly;byday=wed,MO,fr;byhour=8 | accepted as weekly, hours 8, days FR, MO, WE |
| unsupported-frequency | FREQ=HOURLY | rejected |
| unsupported-key | FREQ=DAILY;COUNT=3 | rejected |
| daily-byday | FREQ=DAILY;BYDAY=MO | rejected |
| duplicate-frequency | FREQ=DAILY;FREQ=WEEKLY | rejected |
| duplicate-byhour | FREQ=DAILY;BYHOUR=9;BYHOUR=17 | rejected |
| duplicate-byday | FREQ=WEEKLY;BYDAY=MO;BYDAY=TU | rejected |
| out-of-range-hour | FREQ=DAILY;BYHOUR=24 | rejected |
| duplicate-hour | FREQ=DAILY;BYHOUR=9,9 | rejected |
| duplicate-weekday | FREQ=WEEKLY;BYDAY=MO,MO | rejected |
| duplicate-weekday-alias | FREQ=WEEKLY;BYDAY=MO,MON | rejected |
| empty-hour-entry | FREQ=DAILY;BYHOUR=9,,17 | rejected |
| empty-weekday-entry | FREQ=WEEKLY;BYDAY=MO,,TU | rejected |
| unknown-weekday | FREQ=WEEKLY;BYDAY=XX | rejected |
| missing-frequency | BYHOUR=9 | rejected |
| malformed-part | FREQ=DAILY;BYHOUR | rejected |
| trailing-separator | FREQ=DAILY; | rejected |
| empty-segment | FREQ=DAILY;;BYHOUR=9 | rejected |
| empty-value | FREQ= | rejected |
Time zones and daylight saving
A routine's timezone is utc or an IANA time zone name such as
America/New_York. Slots are wall-clock hours in that zone:
- A wall-clock time that does not exist because clocks spring forward is skipped. That day has no run.
- A wall-clock time that repeats because clocks fall back runs once, at the earlier instant.
A stored routine never keeps local. A request that sends local has it
resolved to an exact zone before the routine is stored. On Unix, a TZ
override must itself be utc or an IANA name; anything else fails the request
rather than falling back to the host zone. Without TZ, the host's zone is
used. The rich coven.automations.command.v1 envelope refuses local
outright. A routine stored as local by an older daemon is resolved when the
store opens, as a new revision recorded with migration
local-timezone-normalization-v1.
11 cases from calendar-schedule-resolution.vectors.json:
| Case | Rule and time zone | After (UTC) | Next slot |
|---|---|---|---|
| daily-same-day | FREQ=DAILY;BYHOUR=9 in utc | 2026-08-28 08:00 | 2026-08-28 09:00 UTC |
| multiple-daily-hours | FREQ=DAILY;BYHOUR=9,17 in utc | 2026-08-28 12:00 | 2026-08-28 17:00 UTC |
| weekly-weekday | FREQ=WEEKLY;BYDAY=MO,WE,FR;BYHOUR=8 in utc | 2026-08-28 09:00 | 2026-08-31 08:00 UTC |
| iana-winter-offset | FREQ=DAILY;BYHOUR=9 in America/New_York | 2026-01-15 13:00 | 2026-01-15 14:00 UTC, 09:00 EST local |
| iana-summer-offset | FREQ=DAILY;BYHOUR=9 in America/New_York | 2026-07-15 12:00 | 2026-07-15 13:00 UTC, 09:00 EDT local |
| dst-spring-gap | FREQ=DAILY;BYHOUR=2 in America/New_York | 2026-03-07 08:00 | 2026-03-09 06:00 UTC, 02:00 EDT local |
| dst-fall-fold | FREQ=DAILY;BYHOUR=1 in America/New_York | 2026-11-01 04:00 | 2026-11-01 05:00 UTC, 01:00 EDT local |
| leap-day-boundary | FREQ=DAILY;BYHOUR=9 in utc | 2028-02-28 10:00 | 2028-02-29 09:00 UTC |
| month-boundary | FREQ=DAILY;BYHOUR=9 in utc | 2026-08-31 10:00 | 2026-09-01 09:00 UTC |
| year-boundary | FREQ=WEEKLY;BYDAY=FR;BYHOUR=8 in utc | 2026-12-31 12:00 | 2027-01-01 08:00 UTC |
| local-timezone-refused | FREQ=DAILY;BYHOUR=9 in local | 2026-08-28 08:00 | rejected: local_timezone_unresolved |
Missed slots, downtime, and clock changes
The scheduler runs a pass when the daemon starts, before its first wait, and then every 60 seconds measured on a monotonic clock. These successful actions wake it for an immediate pass:
coven.automations.create,.update,.delete,.run,.tick, and.unquarantine;definition.create.v1,definition.revise.v1,definition.activate.v1,definition.pause.v1, anddefinition.tombstone.v1;run.cancel.v1,occurrence.cancel.v1,attempt.cancel.v1,attempt.retry.v1,occurrence.recover.v1, andoccurrence.runNow.v1.
Disabling a routine, a rich command.v1 change, and imports take effect at the
next 60-second pass.
Each pass reads the wall clock and applies the misfire policy, which is always
latest:
- After any gap, whether downtime, sleep, or a clock set forward, a pass plans only the most recent due slot. Earlier missed slots never run, and a backfill policy is refused.
- A routine's still-planned older slots settle
skippedwith the reasonsuperseded by latest misfire policy. - Each slot is planned once. A later pass that finds it already planned changes nothing.
- A clock set backward never plans a slot older than one already planned.
- A paused routine never plans.
The daemon does not detect suspend, resume, or clock jumps separately; reading the wall clock on every pass is what covers them.
4 cases from misfire-latest-planning.vectors.json:
| Case | Routine | Timeline (UTC) | Two passes | Planned slots afterwards (UTC) |
|---|---|---|---|---|
| restart-collapses-to-latest-due-slot | ACTIVE FREQ=DAILY;BYHOUR=9 | created 2026-09-01 08:00; observed 2026-09-04 12:00 | planned then already_fenced | 2026-09-04 09:00 |
| existing-latest-fence-replays-idempotently | ACTIVE FREQ=DAILY;BYHOUR=9 | created 2026-09-01 08:00; slot 2026-09-04 09:00 already planned; observed 2026-09-04 12:00 | already_fenced then already_fenced | 2026-09-04 09:00 |
| clock-rollback-adds-no-older-fence | ACTIVE FREQ=DAILY;BYHOUR=9 | created 2026-09-01 08:00; slot 2026-09-04 09:00 already planned; observed 2026-09-03 12:00 | not_due then not_due | 2026-09-04 09:00 |
| paused-definition-never-plans | PAUSED FREQ=DAILY;BYHOUR=9 | created 2026-09-01 08:00; observed 2026-09-04 12:00 | not_due then not_due | none |
The startup cases below check when the pass that launches due work runs. The conformance target's stand-in runtime records the launch and nothing else, so these cases do not show how the run ends.
2 cases from startup-reconciliation-wake.vectors.json:
| Case | Routine at start | Change while waiting | Pass that launched it | Observed |
|---|---|---|---|---|
| startup-runs-due-work-before-first-wait | ACTIVE FREQ=DAILY;BYHOUR=9 | none | startup, 1 launch | launched before the first wait: yes |
| definition-revision-wakes-sleeping-scheduler | PAUSED FREQ=DAILY;BYHOUR=9 | revised to ACTIVE at 2099-09-02 08:00 UTC | wake, 1 launch | action accepted: yes; scheduler woken: yes |
Overlap
The overlap policy is always forbid. A due slot is not claimed while the same
routine has another claimed or running occurrence or a running run; it stays
planned. A settled occurrence does not block. A claim takes a 60-minute lease
for its owner.
7 cases from overlap-forbid-claiming.vectors.json:
| Case | The routine's other work | Due slot claimed | Due slot afterwards | Lease |
|---|---|---|---|---|
| no-overlap-claims | none | yes | claimed, attempt 1 | daemon-a until 2026-09-01 11:00 UTC |
| claimed-occurrence-blocks | claimed_occurrence | no | planned, attempt 0 | none |
| running-occurrence-blocks | running_occurrence | no | planned, attempt 0 | none |
| running-run-blocks | running_run | no | planned, attempt 0 | none |
| succeeded-occurrence-allows | succeeded_occurrence | yes | claimed, attempt 1 | daemon-a until 2026-09-01 11:00 UTC |
| failed-occurrence-allows | failed_occurrence | yes | claimed, attempt 1 | daemon-a until 2026-09-01 11:00 UTC |
| cancelled-occurrence-allows | cancelled_occurrence | yes | claimed, attempt 1 | daemon-a until 2026-09-01 11:00 UTC |
Leases
A claim whose lease has expired before dispatch, with no runtime evidence,
settles failed with the reason lease expired. A lease that expires exactly
now counts as expired. Once runtime evidence exists or the occurrence is
running, the lease no longer settles it; only the session's own evidence does.
5 cases from occurrence-lease-recovery.vectors.json:
| Case | Occurrence | Now, lease expiry (UTC) | Runtime evidence | After lease recovery |
|---|---|---|---|---|
| expired-before-dispatch | claimed | 2026-09-01 10:00, 2026-09-01 09:59:59.999 | no | failed: lease expired |
| exact-lease-deadline | claimed | 2026-09-01 10:00, 2026-09-01 10:00 | no | failed: lease expired |
| unexpired-claim | claimed | 2026-09-01 10:00, 2026-09-01 10:00:00.001 | no | unchanged, claimed |
| runtime-owned-occurrence | running | 2026-09-01 10:00, 2026-09-01 09:59:59.999 | no | unchanged, running |
| claimed-with-runtime-evidence | claimed | 2026-09-01 10:00, 2026-09-01 09:59:59.999 | yes | unchanged, claimed |
Retries and backoff
A routine's retry policy has:
maxAttempts, from1to10;backoffPolicy, one ofnone,fixed, orexponential;backoffSeconds, from1to86400, required forfixed;retryableClasses, chosen fromtransient_dispatch,lease_expired, andruntime_unavailable.
A retry is a new attempt inside the same run and the run's original deadline. The wait counts from when the failure was observed:
noneretries immediately.fixedwaits exactlybackoffSeconds.exponentialpicks a delay between 1 second and a ceiling ofbackoffSeconds× 2attempt − 2, capped at one day (86,400 seconds). The delay is derived from the run id and attempt number, so the same attempt always waits the same time.
When no automatic retry applies, an ordinary run is held running until its
deadline so you can open an operator retry; see
Operator retry.
5 cases from retry-backoff-timing.vectors.json:
| Case | Backoff, next attempt | Delay (range) | Failure observed, retry not before (UTC) |
|---|---|---|---|
| no-backoff-is-immediate | none; attempt 2 of 2 | 0 s (0–0 s) | 2026-09-01 10:00, then 2026-09-01 10:00 |
| fixed-backoff-starts-at-observation | fixed, 60 s base; attempt 2 of 2 | 60 s (60–60 s) | 2026-09-01 10:00:50, then 2026-09-01 10:01:50 |
| exponential-second-attempt-uses-base-ceiling | exponential, 10 s base; attempt 2 of 4 | 8 s (1–10 s) | 2026-09-01 10:00, then 2026-09-01 10:00:08 |
| exponential-fourth-attempt-grows-ceiling | exponential, 10 s base; attempt 4 of 4 | 17 s (1–40 s) | 2026-09-01 10:00, then 2026-09-01 10:00:17 |
| exponential-backoff-caps-at-one-day | exponential, 50000 s base; attempt 3 of 3 | 61621 s (1–86400 s) | 2026-09-01 10:00, then 2026-09-02 03:07:01 |
Quarantine
When a run uses up maxAttempts, the routine is quarantined and the run is held
for an operator retry like any other failed ordinary run. A quarantined routine
stops planning, refuses manual runs and operator retries, and
coven.automations.health reports
nextDueAt as null with the quarantine's time, failure class, reason, and
count of consecutive exhaustions. A later exhaustion keeps the latest evidence
and increases the count. coven.automations.unquarantine releases the routine,
and its next due slot is planned again; releasing a routine that is not
quarantined changes nothing.
4 cases from retry-quarantine-recovery.vectors.json:
| Case | Retry exhaustions (UTC) | Release (UTC) | Quarantine afterwards | Next due slot |
|---|---|---|---|---|
| first-exhaustion-quarantines-and-blocks | runtime_unavailable at 2026-09-02 08:30 | not called | quarantined since 2026-09-02 08:30 for runtime_unavailable, 1 in a row | not planned |
| repeated-exhaustion-updates-latest-evidence | runtime_unavailable at 2026-09-02 08:30; lease_expired at 2026-09-02 08:45 | not called | quarantined since 2026-09-02 08:45 for lease_expired, 2 in a row | not planned |
| explicit-release-restores-scheduling | runtime_unavailable at 2026-09-02 08:30 | at 2026-09-02 08:45 | not quarantined | planned and claimed |
| release-without-quarantine-is-noop | none | at 2026-09-02 08:45, changed nothing | not quarantined | planned and claimed |
Timeouts and cancellation
Every routine has timeoutMinutes, from 1 to 44640 (31 days); the rich
definition shape calls it perRunMinutes. A run's deadline is its start plus
the timeout:
- At the deadline, Coven stops the session and settles the run
failedand its attempttimed_out. - If the stop cannot be confirmed, the run stays unresolved instead of being reported finished.
- A retry still waiting when the deadline passes is settled: the attempt
timed_out, and the run and occurrencefailed. - A run held for an operator retry has no live session. If no retry is opened
by the deadline, the run and occurrence settle
failed, and the failed attempt staysfailed.
When a cancellation and a timeout race, whichever takes the run's stop fence first decides the outcome, and the session is stopped once. For the cancellation commands themselves, see Manual runs and health.
2 cases from cancellation-timeout-arbitration.vectors.json:
| Case | Times (UTC) | Stop fence first taken by | Outcomes | Final states |
|---|---|---|---|---|
| cancellation-stop-fence-defers-late-timeout | cancel requested 2099-10-01 08:29:45; deadline 2099-10-01 08:30; timeout seen 2099-10-01 08:30:01 | cancellation | cancellation cancelled; timeout deferred; session stopped once | run cancelled; occurrence cancelled; attempt cancelled; cancellation cancelled |
| timeout-stop-fence-rejects-stale-cancellation | cancel requested 2099-10-01 08:29; deadline 2099-10-01 08:30; timeout seen 2099-10-01 08:31 | none | cancellation illegal_transition; timeout timed_out; session stopped once | run failed; occurrence failed; attempt timed_out; cancellation rejected |
Previewing a schedule
The daemon has no preview or dry-run action for schedules. To see how a rule
resolves, compare it with the calendar cases above; to see an existing
routine's next run, read nextDueAt from coven.automations.health.
Refused vocabulary
The daemon refuses these rather than approximating them:
- recurrence beyond the subset above;
- misfire
backfill, and any overlap policy other thanforbid; - a durable
localtime zone; - webhook triggers and multi-step pipeline actions;
- an atomic
outputTarget.
The daemon's variantNegotiation capability metadata lists the refused
variants; see Versioned definition commands.
Last updated on