Schedules
A schedule fires a fresh workflow at a configured cadence. Each tick spawns an entirely independent run - no replay accumulation across iterations.
Why a separate primitive
Section titled “Why a separate primitive”The naïve “loop with cryo sleep” pattern doesn’t behave the way
you’d expect:
while true; do healthcheck cryo sleep "1m"doneAfter every wake, the script restarts from line 1 - which means
healthcheck re-runs every replay. After N iterations the script
has executed healthcheck N(N+1)/2 times. (The inner cryo sleep calls fast-forward via the durable log, but plain shell
commands re-execute.) Wrapping the body in cryo step check
fixes the duplication mechanically but loses the “just write a
normal script” ergonomic.
The right answer is a separate primitive for cron-shape work.
Each tick is a fresh workflow; the user’s script is one-shot,
no loop, no cryo sleep, no cryo step ceremony, and no replay
accumulation can appear.
Cron-shape work is its own primitive (each tick fresh); within-execution sleep stays a separate, replay-aware primitive.
Creating a schedule
Section titled “Creating a schedule”# Humantime interval - fires every N units after the last fire.cryo schedule create "1m" health-probe.sh# prints: dev:default:sch-RVXkHnlMdBf83IyG
# Cron expression - fires on the wall-clock cadence in `--tz`.cryo schedule create --cron "0 0 9 * * MON" --tz "America/New_York" \ weekly-report.sh
# YAML pipeline instead of a script.cryo schedule create "1d" --as yaml nightly.yaml
# A stored graph, by reference.cryo schedule create "1d" --graph deploy --input '{"ref":"main"}'Cadence is either a humantime interval (1m, 5h, 1d) or a
6-field cron expression (sec min hour dom mon dow). Cron
accepts an optional --tz (IANA timezone name), defaulting to
UTC; humantime is timezone-agnostic. Each tick submits a
cryosleep/script/v1 workflow with the script body, a
cryosleep/pipeline/v1 run for a YAML schedule, or a
cryosleep-graph/v1 run for a graph schedule.
A graph schedule names a stored graph rather than carrying a file. A bare
name resolves the graph’s published version at every tick, so
republishing changes what future ticks run; deploy@3 pins one stored
version for good.
If that graph declares an
input_schema:, your
--input is checked against it here, when you create the schedule -
a stored input fires forever, and a tick at 3am has nobody to tell.
Ticks are not re-checked. That is deliberate: they would be judged
against whatever version is published at the time, so republishing the
graph with a new required would silently stop every schedule of it
rather than only the ones somebody was about to fix. Republishing a
tightened schema means auditing the schedules yourself.
Passing a graph file where the pipeline or script argument goes is
refused, with a pointer to --graph. There is no inline-graph schedule:
a definition on disk has no stored version for a tick to resolve, so
publish it on the canvas first. The same holds for cryo handler set.
Optional flags:
--name <s>- human label.--tag <t>- tags applied to every spawned run (in addition to the auto-appliedschedule:<id>tag).--as <yaml|script>- treat the file as a YAML pipeline or a polyglot script. Inferred from the extension when omitted.--requires <cap>- capability the agent claiming the run must advertise. Defaults topolyglot. Script schedules only.--graph <name>[@<version>]- run a stored graph instead of a file.--node <kind> [--config <json>]- fire one node kind per tick, with no graph to hold it: a builtin (http,shell,code) or a<connector>.<action>.--inputis the run input each tick submits.--input <json>- the run input for a graph schedule, which the graph’s node expressions read asinput.
Inspecting & controlling
Section titled “Inspecting & controlling”cryo schedule list # status table, with next/last as durationscryo schedule list --json # the stored rows, with absolute timestampscryo schedule show <id> # cadence, policy, and the body it runscryo schedule show <id> --json # the stored row, verbatimcryo schedule run <id> # fire it once, nowcryo schedule pause <id> # stop firing tickscryo schedule resume <id> # resumecryo schedule rm <id> # delete (admin)show prints commented metadata and then the body on its own, so it
pipes to a file and back through edit unchanged:
cryo schedule show sch-abc > update.yaml # then edit the filecryo schedule edit sch-abc update.yamlThe runs page can be filtered by the schedule:<id> tag to see
“every run this schedule has spawned”.
Running one now
Section titled “Running one now”cryo schedule run <id> fires the body once, immediately, and prints
the run id. Both schedule pages in the web UI have the same thing as
Run now, which opens the run it started.
cryo schedule run sch-abc # prints: dev:default:run-9TkQ…cryo schedule run sch-abc | xargs cryo logs -fThis is for the times the cadence is beside the point: you just edited the body and want to watch it work, or the thing it reconciles drifted and 3am is too far away.
It is not a tick, and three things follow from that:
- The clock does not move. A daily schedule you run by hand at 2:55
still fires at 3am. Nothing about
next_fire_at_mschanges. --overlapdoes not apply. It never decides what a manual firing does — you are asked instead, see below — and a manual run does not count as “the previous run” for the next tick. Press Run now at 2:55 and the 3am tick still happens; undercancel_otherthe tick will not kill the run you just started.- A paused schedule still runs. Pausing stops the ticks, not the body — testing a paused schedule is a large part of why you would reach for this.
If the body has a concurrency: group and it is busy, the run waits in
that queue instead of being dropped, which is the difference from a tick:
a tick skips a busy group on the grounds that another is coming. The
exception is policy: skip, which turns runs away rather than queueing
them and does that to a manual firing too — you get “no run started” and
the reason.
There is one place --overlap and a manual run do meet. Under
buffer_one, a held deadline will not fire on top of a manual run that
is executing — it waits for that run to finish, the same as it would
for a tick’s. Without that a buffer_one schedule could end up with two
runs going, which is the one thing that policy exists to stop. A manual
run still waiting for a concurrency: group does not hold the deadline,
because whatever is making it wait will make the released tick wait too.
When it is already running
Section titled “When it is already running”Press Run now while runs of that schedule are still going and nothing starts. You are shown what is in flight, and Run alongside starts another one anyway. The CLI is the same:
cryo schedule run sch-abc --alongsideWithout it, cryo schedule run prints what is running and exits
non-zero without starting anything.
There is no “cancel the running one and start mine”. Replacing a run is
a property of the body, not of one press: say it with a
concurrency: group and policy: cancel-running,
which applies to ticks and manual runs alike and is enforced at
admission rather than by a best-effort cancel from outside.
A run waiting for a busy concurrency: group counts as already running
too, so pressing again tells you rather than quietly queueing a second
one.
The run carries the usual schedule:<id> tag, so it shows up in the
schedule’s history with everything else, plus trigger:manual to tell
it apart from a run a deadline produced. If something reads a
schedule’s runs to reason about its timing, filter that tag out.
Graph and node schedules run with the --input they were created with.
There is no per-firing input override.
Three answers print no run id and exit non-zero:
- Something is subscribed to
schedule.tick. Handlers own the firing the same way they own a real tick, so the schedule’s own body does not run. Their runs carry this schedule’s tag; there is no single id. - The start funnel turned it away — a full concurrency queue, a
throttle:over its rate. The reason is printed. - Runs of it are already going and you did not pass
--alongside. What is running is listed.
Editing
Section titled “Editing”cryo schedule edit <id> takes the same flags as create and changes
only what you pass. The schedule keeps its id, so the schedule:<id>
tag on every run it has already spawned still resolves - which is what
delete-and-recreate costs you.
cryo schedule edit sch-abc --cron "0 0 3 * * *" --tz Europe/Stockholmcryo schedule edit sch-abc nightly.yaml # new body, same schedulecryo schedule edit sch-abc --overlap buffer_one --jitter 5mcryo schedule edit sch-abc --input '{"ref":"release"}' # graph schedulescryo schedule edit sch-abc --name '' # an empty value clearsA cadence or jitter change re-measures the next deadline from now. Everything else leaves the clock where it is, so a body edit takes effect on the next tick without moving it. The same edits are on the schedule’s page in the web UI.
Policy
Section titled “Policy”- Cadence: humantime intervals or 6-field cron expressions
(
sec min hour dom mon dow) with an optional IANA timezone. - Overlap:
--overlap, defaulting toskip. See below. - Timezone: UTC for humantime and for cron without
--tz; otherwise the cron’s explicit--tz.
When a tick arrives and the last one is still going
Section titled “When a tick arrives and the last one is still going”--overlap says what happens. The deadline advances either way - the
policy decides what the tick does, never whether the schedule keeps
ticking. It governs ticks against ticks; a run someone started with
schedule run is outside it on both sides.
--overlap |
The arriving tick |
|---|---|
skip (default) |
is dropped. A slow run costs you the ticks it overran. |
allow |
fires anyway. Ticks overlap. |
buffer_one |
is remembered, and fires when the running one finishes. |
cancel_other |
cancels what’s running, then fires. |
cryo schedule create 5m --overlap buffer_one reconcile.shReach for buffer_one when a tick must not be lost - an
every-5-minutes reconcile that occasionally takes six does half as much
work for as long as it’s slow, and the slow stretches are when it
matters. It holds one missed deadline, not a queue: a schedule
persistently slower than its cadence would otherwise build a backlog it
can never drain, which is what skip exists to avoid.
Reach for cancel_other when a new tick makes the old one pointless - a
deploy-latest, a cache warm. Cancelling a run
takes its children with it,
so a superseded tick leaves no subtree behind.
allow is for ticks that are genuinely independent - a prober, a
heartbeat - where skipping loses a sample rather than saving you one.
A pipeline’s own concurrency: group is a separate, later gate. Overlap
decides whether the tick starts at all; if it does, the group decides
what happens next, and by default that’s a queue behind the holder. A
tick still waiting in that queue has no run yet, so the next tick’s
overlap check can’t see it - with allow and a concurrency: group you
can build a backlog that neither gate trims.
Spreading the herd
Section titled “Spreading the herd”Fifty schedules written 0 * * * * all fire at the top of the hour.
--jitter offsets each deadline by a random amount within the window
you give it:
cryo schedule create --cron '0 0 * * * *' --jitter 5m warm-cache.shThis schedule lands somewhere in the five minutes after the hour, and the next one you create with the same flag lands somewhere else in that window. The offset is drawn once per schedule and then held: every tick of a given schedule sits at the same point in the window, so an hourly schedule stays exactly hourly, it just isn’t punctual. Two consequences worth knowing:
- The offset is stable across restarts and across server versions. A schedule that settled on +3m12s keeps firing at +3m12s.
- It shifts every deadline by the same amount, so a cadence with slots
closer together than the jitter window keeps all of them -
*/5with--jitter 5mstill fires twelve times an hour.
Don’t set it on a schedule whose ticks have to land on the minute.
--jitter is capped at 24h.
No backfill of missed ticks
Section titled “No backfill of missed ticks”There is no catch-up. If the instance is down across several
scheduled ticks - a cryo dev you closed for the weekend, a server
that was offline for an hour - those missed ticks do not fire
when it comes back. Only the next future tick fires, once. A cadence
of 1m that was down for an hour does not spawn 60 runs on restart;
it spawns one, at the next minute boundary.
If you need every interval accounted for, don’t lean on the schedule to reconstruct the gap. Have each run record its own coverage (a watermark, a “last processed” marker) and pick up from there.
Idempotency
Section titled “Idempotency”The driver advances last_fired_at_ms / next_fire_at_ms before
submitting the run. A crash between advance and submit miss-fires
one tick (inherent to recurring work). A crash after submit but
before advance causes one duplicate fire on restart; the duplicate
run completes independently.
What about loops with side effects?
Section titled “What about loops with side effects?”Sometimes you do want a single durable workflow that loops forever - e.g. “monitor a queue and process incoming items indefinitely, surviving restarts.” For that, structure the loop as a polyglot script:
while true; do cryo step iter -- bash -c 'process-one-item' cryo sleep "5s"doneEach iteration records one journal entry; the run’s durable log grows
linearly. The cryo sleep between iterations is suspend-and-
replay: the script re-runs from the top on wake and prior
cryo step iter calls return their cached output until the loop
reaches the uncached iteration. For “every minute forever” use a
schedule (each tick is a fresh log). For “loop until done” with
bounded iterations, the in-script loop is correct.