Skip to content
Like what we’re building? Star on GitHub

Scheduling recurring bot runs (iterion schedule) ​

Some bots are meant to run on a clock, not on demand — a weekly security audit, a nightly docs-refreshment pass, a periodic dependency sweep. iterion schedule wires those into the host's own cron so they fire on time without keeping an iterion process resident. This is the complement to iterion dispatch: the dispatcher is an always-on loop reacting to a tracker; the scheduler is a set of cron triggers reacting to the clock.

Diagram: Flow — triggers and schedules: five sources, two deliveries, three clocks

Model ​

A declarative manifest is the single source of truth. schedule install materialises it into a managed block of the host crontab; each cron line calls a POSIX shell wrapper that sets PATH and executes iterion schedule run <name>, which re-reads the manifest and executes the run in-process. Because the host scheduler is the trigger, nothing iterion needs to stay running between firings.

~/.iterion/schedules.yaml          # the manifest (override with --manifest or $ITERION_SCHEDULES_FILE)
~/.iterion/schedules.yaml.sh       # generated POSIX wrapper, beside the manifest
~/.iterion/logs/schedule-<name>.log  # per-schedule stdout+stderr, appended each run
host crontab                       # a managed block, markers below, regenerated by `schedule install`

The manifest is host-wide (a host has one crontab), and every entry carries its own workdir, so a single manifest can schedule bots across several repositories.

Manifest format ​

yaml
version: 1
schedules:
  - name: sec-audit-source-weekly      # unique; used in the crontab line + log filename
    cron: "0 2 * * 1"                   # standard 5-field expression, passed opaquely to host cron
    bot: bots/sec-audit-source/main.bot
    workdir: /home/jo/lab/ai/iterion    # cd here before running; bot path resolves against it
    store_dir: ""                        # optional override; empty uses project-store resolution
    sandbox: ""                          # optional --sandbox override (none|auto)
    timeout: "2h"                        # optional max run duration (guards a hung run)
    vars:                                # optional --var overrides (commas kept verbatim)
      label_source: sec-audit-self
    description: Weekly SAST self-audit  # optional; emitted as a crontab comment
    disabled: false                      # keep in the manifest, leave out of the crontab
    overlap: skip                        # optional: skip (default) | allow | keepalive — see "Overlap policy"
    max_concurrent: 0                    # optional, with overlap: allow — cap on live runs (0 = unlimited)
    guard: ""                            # optional pre-launch sh -lc gate — see "Guard command"
    guard_timeout: "30s"                 # optional guard subprocess timeout
    guard_var: guard_output              # optional var name receiving the guard stdout

With an empty store_dir, the run uses the normal resolution anchored at the bot path: an existing managed project .iterion, otherwise the deterministic slot under $ITERION_HOME/projects/ (normally ~/.iterion/projects/). Set an explicit <workdir>/.iterion when scheduled runs must appear in a studio bound to that workspace store.

Commands ​

CommandWhat it does
iterion schedule add <name> --cron … --bot … [--workdir …] [--var k=v]… [--store-dir …] [--sandbox …] [--timeout …] [--description …] [--disabled] [--overlap skip|allow|keepalive] [--max-concurrent N] [--stale-after 5m] [--guard …] [--guard-timeout 30s] [--guard-var guard_output]Add or update an entry (upsert by name). Overlap, keepalive (--stale-after), and guard flags mirror the manifest fields — see "Overlap policy", "Always-on agents", and "Guard command".
iterion schedule list [--json]List manifest entries.
iterion schedule remove <name>Delete an entry from the manifest.
iterion schedule run <name> [--dry-run]Execute one entry now — what cron invokes. Applies the overlap policy and the guard before launching. --dry-run prints the resolved iterion run command without executing.
iterion schedule audit [--name X] [--surface host-cron|trigger|cloud] [--since 24h] [--tail 50] [--json]Show the tick-decision history: every fired / skipped / guard outcome with its reason — the deterministic answer to "why didn't my scheduled bot fire last night?".
iterion schedule install [--print] [--tz UTC]Write the wrapper and sync the manifest into the host crontab. --print previews only the block without writing the wrapper or touching the crontab (works even where crontab is absent); run without --print to install both.
iterion schedule uninstallRemove the iterion-managed block from the host crontab (manifest left intact).

--manifest <path> is available on every subcommand.

add/remove only edit the manifest — run schedule install afterwards to push the change into the crontab.

The managed crontab block ​

schedule install reads the current crontab, replaces (or appends) the block delimited by:

# >>> iterion schedules (managed by `iterion schedule install`) >>>
…
# <<< iterion schedules <<<

User-authored crontab lines outside the markers are preserved untouched, and re-installing is idempotent (the block is replaced, never duplicated). Two details make scheduled runs actually work in cron's minimal environment:

  • CRON_TZ=UTC (override with --tz) — honoured by cronie/Vixie cron so schedules fire in the intended zone regardless of host local time; on other cron implementations it is a harmless env var, so verify the firing time against your cron's timezone semantics.
  • <manifest>.sh wrapper — /bin/sh runs this script, which exports the sanitized install-time PATH and execs the absolute iterion binary with the cron arguments. The crontab contains no PATH assignment: long environment lines can be silently truncated by cron. The wrapper drops empty and relative entries, entries containing CR/LF/NUL, and duplicates, preserving the order of the remaining entries. It appends missing /usr/local/bin, /usr/bin, and /bin, then drops whole entries from the tail to fit 8,192 bytes, always preserving those three system paths. ITERION_SCHEDULE_PATH_MAX_BYTES overrides this limit (0 disables trimming; a positive limit must fit the 27-byte system PATH).

Installation lists every dropped entry with its reason and prints the wrapper location. A wrapper comment records the original and sanitized PATH sizes in bytes. Reinstall after changing your tools or PATH. The wrapper is written with mode 0700 through a synced temporary file and atomic rename before the crontab is replaced, so cron reads a complete script on reinstall. If the crontab replacement fails, installation restores the previous wrapper (or removes it on a first install) and reports any restoration error. uninstall removes the cron block and leaves the manifest and wrapper intact. If sandbox driver selection fails, its diagnostic names the host process's PATH and working directory at selection time, plus each rejected driver. Long PATH diagnostics show the beginning and end with a truncation marker; check these entries against the wrapper before reinstalling Docker or Podman.

A rendered block looks like:

# >>> iterion schedules (managed by `iterion schedule install`) >>>
# Managed by iterion — edit the manifest then `iterion schedule install`.
# Remove with `iterion schedule uninstall`.
CRON_TZ=UTC
# Weekly SAST self-audit
0 2 * * 1 cd /home/jo/lab/ai/iterion && /bin/sh /home/jo/.iterion/schedules.yaml.sh schedule run sec-audit-source-weekly --manifest /home/jo/.iterion/schedules.yaml >> /home/jo/.iterion/logs/schedule-sec-audit-source-weekly.log 2>&1
# <<< iterion schedules <<<

Example — weekly self-audit ​

sh
iterion schedule add sec-audit-source-weekly \
  --cron "0 2 * * 1" --bot bots/sec-audit-source/main.bot --workdir "$PWD"
iterion schedule add sec-audit-deps-weekly \
  --cron "0 3 * * 1" --bot bots/sec-audit-deps/main.bot --workdir "$PWD"

iterion schedule run sec-audit-source-weekly --dry-run   # sanity-check the resolved command
iterion schedule install                                  # write the crontab block
crontab -l                                                # verify

The audit bots label their findings source:sec-audit-self on the native board (see the security self-audit page). They pin the iterion-sandbox-sec image via sandbox.image, so the host needs that image present (CI publishes it; for a local loop, docker tag your build to ghcr.io/socialgouv/iterion-sandbox-sec:edge).

Note: sec-audit-source (SAST) is production-ready. sec-audit-deps (SCA) now has a real CVE floor — run_generic_heuristics runs trivy fs --scanners vuln over the workspace from a bare checkout, matching pinned versions against the OSV/GHSA/NVD DB. The per-ecosystem npm/pip-audit and code-pattern/typosquat malware signals remain partial, so a run still self-labels with a "⚠ Coverage" banner — but it is no longer a zero-finding scaffold.

Overlap policy — skip is the default (behavior change) ​

Since the schedgate integration, iterion schedule run does not fire while a previous run of the same schedule is still live (any non-terminal status: running, queued, paused_*). Before, every tick launched unconditionally — a nightly bot that overran its window silently piled up concurrent runs on the same repo. Skip-by-default fixes that latent bug; the trade-off is deliberate and loud:

  • Every decision is recorded in the tick audit (<manifest-dir>/logs/tick-audit.jsonl, read with iterion schedule audit). A skipped tick writes skipped_overlap with the blocking_run_id, so you find out which run held the slot instead of wondering why nothing ran.
  • A stale run stuck in running (crashed host) blocks its schedule until it is reconciled or cancelled. iterion does not guess a staleness cutoff: the audit names the run — inspect it, then iterion resume or cancel it. The studio/server's orphan reconciliation flips flock-releasable orphans automatically.
  • Opt out per entry with overlap: allow (unbounded) or overlap: allow + max_concurrent: N (fire while fewer than N runs of this schedule are live, counting the one about to start).
  • After upgrading, watch iterion schedule audit --since 48h once: a schedule that used to rely on implicit overlap shows up as skipped_overlap there.

Overlap counting keys on run provenance: schedule-launched runs are stamped source.kind: schedule + source.schedule_id in run.json, which also makes them attributable in the studio and queryable via the store.

Always-on agents — overlap: keepalive ​

overlap: keepalive runs a bot continuously: every tick relaunches it, but with at-most-one-live semantics and a staleness cutoff. It is how you keep an agent (a watcher, a poller, your own long-lived bot) alive as a stream of fresh, individually-budgeted runs rather than one immortal run that fights max_duration, per-node deadlines, and the cloud budget ceiling.

The only difference from skip: a run that is silent past stale_after (default 5m; a running run whose last progress is older than the cutoff) stops counting as live, so the next tick relaunches a fresh run and the zombie is reaped (running → failed_resumable, so it is still inspectable/resumable). This closes the "crashed run stuck in running blocks the schedule forever" gap above — a dead always-on agent recovers on its own within one tick.

yaml
# schedules.yaml — an always-on watcher, relaunched every minute,
# recovered if it goes silent for 2 minutes.
- name: watcher
  cron: "* * * * *"           # host crontab floors at 1 minute
  bot: bots/my-watcher/main.bot
  overlap: keepalive
  stale_after: 2m
  • Sub-minute cadence is not expressible on the host crontab (its floor is 1 minute). For a faster pulse, run the resident scheduler (iterion studio / iterion server) and author the bot with a kind: keepalive invocation carrying an interval: (see below) — the in-process scheduler ticks every 15s by default (ITERION_SCHEDULER_INTERVAL, which paces the cloud ticker too).
  • max_concurrent is invalid with keepalive (at-most-one-live is the point).
  • stale_after should be ≥ the bot's max_duration so a long-but-alive run is never falsely reaped. Staleness only ever applies to running runs — a paused_waiting_human run is legitimately idle and never reaped.

Authoring an always-on bot (kind: keepalive) ​

A bot declares its always-on capability in its manifest.yaml; the cadence lives on the invocation (not baked into the bot's logic):

yaml
invocations:
  - kind: keepalive
    keepalive:
      interval: 30s            # >= 5s; sub-minute needs the resident scheduler
      stale_after: 5m          # optional (default 5m)

Enable it one-click from the bot's studio home (the /bots/{name}/triggers/from-invocation route): the trigger subscription it stores is fired by the resident scheduler, which a cloud replica does not run. In cloud, toggle Always-on on a schedule in the studio Schedules tab instead — that row is what the cloud ticker fires. To also steer each launched run mid-flight (correct it, nudge it), give the bot a supervisor: block — the engine auto-attaches a supervisor coordinator to every keepalive run, no extra wiring.

Guard command — fire only when there is work ​

guard: is an optional sh -lc snippet executed before any launch, in the entry's workdir, with ITERION_SCHEDULE / ITERION_SCHEDULE_BOT in the environment and a hard guard_timeout (default 30s) on its own context:

  • exit 0 → the run fires, and the guard's stdout becomes the run's vars[guard_var] (default guard_output) — a cheap way to hand the run "the work found" (an issue list, a diff summary).
  • exit non-zero → the tick is skipped (guard_blocked in the audit, exit code + stderr tail captured).
  • guard breaks (spawn failure, timeout) → guard_error in the audit — deliberately distinct from guard_blocked, so "the guard said no" never masks "the guard is broken".

On a cloud deployment the guard would run in the server pod, on behalf of whoever manages the team's schedules: a cloud server refuses guard: (422) unless the deployment sets ITERION_CLOUD_SCHEDULE_GUARDS=allow, and a guard stored before does not run — its tick is recorded guard_error, with the reason, and that reason is raised on the schedule's own last_error, so a schedule that stopped producing runs says why where its operator reads it (iterion remote schedules list) rather than only in the audit trail; a later tick whose guard actually runs clears it again. The same refusal covers a trigger subscription's guard: — in both cases it is about a guard the request sets, so a stored one stays editable and the trigger can be disabled. See environment-variables.md.

Example — only run the fixer when ready-labeled issues exist, and pass them in:

yaml
- name: fix-ready-issues
  cron: "*/30 * * * *"
  bot: bots/feature-dev/main.bot
  workdir: /home/jo/proj
  guard: 'out=$(gh issue list --label ready-for-agent --json number,title); [ "$out" != "[]" ] && printf %s "$out"'
  guard_var: issues_json

Idempotence stays with the workflow (mutate the state you poll — e.g. the bot relabels the issue), exactly like the dispatcher's tracker loop: the guard is a gate + input source, not a dedup engine.

Retry — a provider quota window is waited out, not re-attempted ​

A scheduled run that dies because the LLM provider's quota window is exhausted (the Anthropic forfait 5h / session / daily / weekly caps) is not a failure of the run: nothing about it is wrong, it just arrived while the door was shut. Retrying it inside a node's retry budget, or handing it back to the work queue, cannot help — a weekly reset can be seven days away, and each attempt costs a fresh pod against a wall that will not move.

So iterion waits for the reset and resumes, and the wait is durable (cloud mode: the intent lives in the run document, a server-side sweeper acts on it). The schedule itself is unaffected: it keeps firing on its own cadence, and the retry is about the run that already failed.

yaml
retry:
  usage_window: resume   # resume (default) | off
  max_attempts: 5        # over the run's WHOLE lifetime; never reset
  max_wait: 192h         # cap on how far ahead a retry may be scheduled (8d)
  jitter: 10m            # spread runs that share one reset instant

Where to declare it. The same block is accepted on four layers, and each one overrides only the fields it actually sets:

prioritylayerwhere
1per-run overridelaunch API / CLI
2launching surfacea cloud schedule row, a trigger subscription, a webhook config, a schedules.yaml entry — each exposes the same four retry_* fields
3the botretry: in the bundle's manifest.yaml
4machine defaultITERION_RETRY_USAGE_WINDOW / _MAX_ATTEMPTS / _MAX_WAIT / _JITTER

A platform ceiling (ITERION_CLOUD_RETRY_MAX_ATTEMPTS, ITERION_CLOUD_RETRY_MAX_WAIT) is applied last and can only lower a resolved policy, so a tenant cannot reserve a hundred attempts over thirty days on their own schedule.

Local vs cloud — the wait is held differently. In cloud mode the intent is durable, so nothing extra is needed. On a host-crontab schedule the wait is held in-process by the bounded auto-resume loop, which stays opt-in: the retry_* fields on a schedules.yaml entry shape that wait, they do not enable it. Add --auto-resume N (or ITERION_AUTO_RESUME) to turn it on. The reason it is not on by default is that a local iterion run blocks your terminal, and silently sleeping it for 33 hours is not a default anyone would want.

Choosing a value. The question a bot author is answering is is this output still worth having late? A weekly digest is (resume); a "what changed in the last hour" report is not (usage_window: off — let the next tick produce a fresh one). max_wait bounds one scheduled sleep, not the run's total lateness: if the parsed reset is farther away, iterion schedules the retry at that horizon and re-evaluates when it wakes. For example, 36h means "wake no later than 36 hours from now and check again"; max_attempts is the separate whole-lifetime bound on repeated waits.

What you see. The run stays failed_resumable while it waits, with a run_retry_scheduled event naming the instant, the attempt number and how the instant was derived (reset_source). When the window reopens the run emits run_auto_resumed and continues from its checkpoint — the same pair the CLI's in-process --auto-resume loop writes, so the timeline reads identically local and cloud. A run that stops retrying records why (budget spent, admission denied, source no longer resolvable) rather than going quiet.

Observing a reset nobody announced (cloud). An operator who tops up or clears their forfait by hand reopens the window with no notice, and a retry armed for the date the provider named is invisible to every sweep until that date — six reviews once slept four days on a wall that had fallen the first afternoon. So the sweeper also runs a pull-forward probe: it moves a not-yet-due usage_window retry to now, and the ordinary pass resumes it on its next tick. The runner's usage preflight is the authoritative reading of the window and it refuses before anything spends, so the worst case is one wasted pod and a retry re-armed on the instant the provider names; the best case is a run that comes back hours or days early.

The probe is bounded, and its bounds are the deployment's:

VariableMeaningDefault
ITERION_RETRY_PROBE_FLOORHow long a wait rests between probes, and how close to its announced instant probing stops being worth a pod1h
ITERION_RETRY_PROBE_BUDGETProbes one armed wait may spend; 0 turns probing off24

The probe ships armed: a deployment that sets neither variable probes every parked wait hourly, up to twenty-four times — a bounded day of observation, then the run simply waits for the instant the provider named. That is the decision, and the bet it makes is one pod an hour per parked wait (the price this codebase already accepts for a refusal that names no instant at all) against a run sleeping through a window that reopened the first afternoon. ITERION_RETRY_PROBE_BUDGET=0 disarms it in one variable, with no code change and no redeploy of anything else.

The budget is per wall, not per arming. The provider's own instant is persisted on the retry row (RunRetryState.Wall), written by the park that decided it — from the refusal, never from retry_after, which also carries the jitter, the five-minute floor, the max_wait ceiling, a skipped credential's earlier wake and the shared circuit's deferral. The probe budget is cleared by an arming that MOVES that instant and by nothing else, so a charged park that merely re-states the wall the row is already on keeps it: the ceiling on one provider window is budget probes, beside at most max_attempts charged parks. A wall the provider genuinely moves is a new wait and is probed afresh.

The pace (last_probe_at, one pull per floor) belongs to the ROW, not to the wall: no arming clears it, whatever it does to the wall. The scan reads an absent stamp as "never probed", so an arming that cleared it made the row eligible again on the sweeper's very next pass — one pod, one owner notification and one pull-request comment per sweep tick, charged no attempt and bounded by nothing.

And only the provider's own dated word moves a wall. A refusal that names a window DURATION ("reached for 5 hour") resolves against the moment it is read, so believing it re-dated the wall on every park — a fresh episode, a fresh incident, a fresh notice and a fresh budget, for one window nobody had news about. Such a refusal, a blind guess and a park that restores the instant a probe set aside all say nothing new: the row keeps the wall it is on while that wall is still ahead. A wall that has PASSED is spent news and the next park names its own. Which refusals are dated is asked of the parser, which knows which of its patterns matched: a duration alone is this reading's own resolution, and so is an absolute instant a producer rendered INTO a sentence that also states the duration — the zai facade's "reached for 5 hour. Your limit will reset at …", whose instant sits within a minute of now+that duration (anything further is a real dated wall refused mid-window, with the time that REMAINS of it). A typed reset instant is the same ask, of the text it was resolved from, because the typed field is frozen: the claude_code classifier stores the parser's answer, and re-reading the field cannot see what it came from. A refusal whose words carry no hint at all keeps the typed instant's word — the operator's own cap window names a date. ITERION_RETRY_PROBE_BUDGET=0 remains the one-variable escape hatch for the pass as a whole.

A probe spends no attempt: max_attempts is reserved for work that gets past the preflight, so a re-failure that followed a probe re-arms for free (a probed wait that charged attempts would retire a run before the wall it waits on falls — five hourly probes exhaust the default budget in five hours) — free while that park follows its pull inside the marker's TTL, which is where the exception and its price are stated, below. And a probe never shortens a run's life. Four things carry that guarantee, because each of them would otherwise break it:

  • the sweeper puts the announced instant back instead of re-arming or giving up when a probe-pulled resume is denied or cannot be launched, leaving that decision to the date the provider named;
  • a probed re-failure that names no instant keeps the announced one: the fallback for an unparseable refusal is a one-hour guess, and writing a guess over the provider's own word deletes the wall the run is waiting on. The rule reads the refusal, not the reset source it produced. A dated re-failure still wins, including an earlier one — good news from the provider is not a probe's to override;
  • a credential the launch skipped may reopen sooner, and a row holding the announced instant does not weigh it at all — on either arming. The earlier wake looks free, but the write that takes it consumes the marker in the same update, so the only durable copy of the provider's word would be traded for a speculation about another key, and a refusal naming no instant then walks the run down hourly guesses until the budget retires it (measured at the production defaults: five guesses, retired 96 h before the wall). While the wait's probe ledger is under its budget the credential loses one probe floor and not the wall: the row stays armed on its wall, a free re-arm leaves the ledger where the pull left it, and the next probe gives that key its chance one floor later, free. Once the budget's last probe has been pulled, no pass lists that wait again, so what the key loses is the REST of the wait: the run sleeps to the announced instant whatever another credential does. That is the bound on keeping the wall, and it bites where the wall is further out than budget × floor — a day at the defaults;
  • a paid arming refused on a spent budget restores that instant too, rather than retiring the run; only a row with no marker left is ever settled;
  • and what is free is the probe's own refusal, not every park that follows one. The marker outlives the resume it was written for (the pod reads it off the row), so a run the probe pulled forward, that resumed because the window really had reopened and burnt it again, would otherwise re-park free against an instant that belongs to a window it has already used. Tokens on the clock tell the two apart: an execution that got an answer from the provider parks on a new wall — charged, counted, fed to the shared circuit, and announced on the pull request. That spend decides who pays, and only that: the announced instant survives a refusal that names none whoever pays for the arming, because the same tokens are reported by a node whose every route was refused after one of them burned a session that died, and trading days of waiting for hourly guesses on that reading retires the run the probe exists to save.

The marker answers two questions, and they expire differently. Which instant — the provider's own dated word about this row's wall — holds while that instant is still ahead, however old the pull is: it is the only dated fact the row has, and the settlement, the restore and the max_wait anchor all read it there. Who pays holds only while the pull is recent (one hour, the probe pace's own default floor). Only an arming deletes the marker, so a probe-driven resume that never parks — it succeeded, its pod was evicted — leaves it behind for the rest of the wait, and a park days later, which is nobody's probe, would otherwise take the whole free treatment: uncharged, uncounted, unfed to the shared circuit, its resume refunded and the pull request silent on a wall it never heard of. Once the instant itself is behind us the wait is over and both answers are no: the park is charged and announced, the resume is a billable launch, the settlement is the sweeper's ordinary one.

That hour is a constant rather than a knob because both errors it can make are mild: too short, a genuinely probed park (a resume the shared circuit deferred for longer) is charged one attempt and its resume is metered — max_attempts of each per run — but it re-states the same wall, so it clears no ledger, starts no fresh budget and says nothing new on the pull request; the announced instant survives all of it and the run still waits on its wall. Too long, one later park within the hour that follows a pull is free. The escape hatch is the pass itself — ITERION_RETRY_PROBE_BUDGET=0 writes no marker at all.

A probe costs a pod, not a quota unit — while its resume follows the pull inside the marker's TTL (above): that resume passes the same admission gate as any launch (a suspended org, an exhausted monthly quota or a concurrency cap still stops it, and the row is simply re-armed on its announced instant), but the metered increment is handed back — a launch whose refusal is expected must not spend the tenant's monthly run quota, which is a hard paywall until the month rolls over. A resume the shared circuit or a sweeper outage delays past that hour is nobody's probe: it is metered like any launch, and the park it causes charges an attempt — max_attempts of each per wait, the price the TTL's own paragraph states. A run the probe finds genuinely reopened therefore runs on the unit its original launch metered — once per probed wait: the park that follows that run's real work is charged like any other, which closes the probe ledger, so every resume after it is metered again.

A forfait nobody resets is probed for a bounded day and then simply waits — per parked run, so a large parked fleet multiplies the pods and is where lowering the budget (or raising the floor) earns its keep. The day is per provider WALL: a run's own ceiling is budget probes per wall the provider names, whatever the attempts spent re-stating it. The pass pulls at most two rows a minute whatever the fleet: every row it pulls becomes due, and a probe batch as wide as the due pass's would fill the capacity that resumes runs whose own instant has arrived.

One park, one audience, per wall. The persisted wall is the unit of a usage park for everything that observes one, and each reads it from the row: the run owner's push notification (its own kind, "paused on a usage window", with the wall folded into the episode id — trigger.RunUsageParkEventID), the operator's incident (pkg/alert, same key), and the pause notice on the pull request a parked run gates. On that pull request the park that OPENED the wall is the one that comments; a probe's re-park stays quiet, and so does a charged park that merely re-stated the instant already named there, because the probe ledger the notice reads belongs to the wall. When the provider's fresh refusal names a different instant, the arming moves the wall, clears that ledger, and the pull request hears the new instant there and then — it no longer waits for the next charged park. The operator's own path stays iterion remote runs resume --usage-blocked, which sweeps and resumes every blocked run at once.

Which reset the wait is armed on (cloud). The credential a run fails on is the one the launch's credential walk fell through to — and the one it passed over often reopens first: a team key refused on its five-hour window reopens the same afternoon, while the platform forfait the run landed on is walled until Monday. The publisher therefore stamps the run with the earliest reopening among the credentials it skipped (skipped_cred_reopens_at, re-stamped on every resume), and the retry arms on the earlier of that and the failed credential's own reset — the event then reads reset_source: skipped_credential and carries skipped_cred_reopens_at. The resume re-resolves the whole chain, so coming back at that instant lands on the reopened key. A skipped credential that has already reopened by the time the run parks means "re-resolve now": the retry lands at the five-minute floor.

That earlier wake is a guess — the skipped credential may be refused too — and it spends an attempt of the same max_attempts budget as a wake on the real reset. So the last attempt the budget allows is reserved for the failed credential's own reset whenever that reset is still ahead and inside max_wait: the event then reads reset_source: typed_error+last_attempt_pinned (or runtime_code+…, whichever evidence named the instant). Without it, five wakes on a five-hour cycle cover 25 hours of a seven-day window and the run is abandoned days before the wall it waits on falls. Earlier attempts still take the early wake, so the recovery above is unchanged; and when the reset lies past max_wait, or the provider named no instant at all, nothing is reserved — there is no reachable wall to reserve for.

Not covered by this: a budget cap (max_cost_usd and friends) still needs a human to raise the cap and resume — retrying the same cap would re-fail instantly. Nor an auth failure, which is a credential problem time does not fix.

Seeing cloud schedule failures ​

In Studio, Automations → Schedules shows the last completed run's status, error code, message and link to the run. A refused launch is shown separately: it means the scheduler could not start a run, rather than that the run failed. The run detail uses the persisted failure code for its recovery hint, including when another error wraps the original message.

A failure keeps the configured cadence. For example, SANDBOX_DRIVER_UNAVAILABLE requires fixing the configured sandbox driver: Docker/Podman installation and PATH on a local runner, or Kubernetes configuration and access on a cloud runner. The next scheduled run uses the repaired environment. Use Pause while making that repair if repeated launches are undesirable, then Resume when ready. A later successful run clears the failure advice.

For CLI inspection, iterion remote schedules list returns the cloud record as JSON, including last_run_status, last_run_error_code, last_run_error and last_error. iterion schedule list lists the local cron manifest instead; it does not contain cloud run outcomes.

The schedule is one of four records that carry this tuple: a trigger subscription (cloud REST API) and a board card (native tracker) report the same way, written by one spine (pkg/server/outcome_spine.go) and rendered by one studio component.

Retention — pair recurring schedules with iterion runs prune ​

Every scheduled run persists forever under <store-dir>/runs/<run_id>/; the store has no built-in retention. A weekly bot alone adds ~50 run directories a year, and a fleet of hourly/daily bots pushes that into the low thousands per month. Cap disk usage by running iterion runs prune on its own crontab line — the schedule manifest only runs bots, not arbitrary commands, so this one belongs outside the managed block:

# Weekly retention sweep — keep the last 100 runs and prune anything
# finished/failed/cancelled that is older than 30 days.
30 3 * * 1 /usr/local/bin/iterion runs prune --store-dir /path/to/workspace/.iterion --older-than 720h --keep-last 100 >> "$HOME"/.iterion/logs/runs-prune.log 2>&1

Flags mirror the semantics of the shipped statuses — see iterion runs prune --help for the full list. --dry-run is the safe way to preview what a candidate retention policy would delete before committing to it in the crontab. The command only removes <store-dir>/runs/<id>/ directories; it never touches <store-dir>/worktrees/ or anything else.

failed_resumable runs are excluded by default (they are recoverable); opt in with --status finished,failed,cancelled,failed_resumable when you have accepted their loss.

Notes & limits ​

  • Cron expressions are passed through verbatim. iterion only checks the field count (5); range validity is the host cron's job.
  • Host cron only. schedule install/uninstall shell out to crontab. On a host without it (e.g. a minimal container, Windows), schedule install --print previews the cron block but does not create its wrapper. Configure your scheduler (systemd timer, Task Scheduler, a CI cron) to invoke iterion schedule run <name> directly with its required PATH and working directory. The manifest + `schedule run<name>` work everywhere.
  • No TTY at firing time. Scheduled runs are non-interactive; a bot that pauses for human input will pause and persist a resumable checkpoint (resume it later with iterion resume), not block the cron job.
  • A repo-bound schedule mints its clone token at the tick. A schedule whose repo_url matched a team repo integration at creation carries repo_integration_id; each tick resolves that integration's connection and mints/refreshes the MANAGED secret (EnsureManagedSecret — the same path a studio/API launch uses), pinning it as the run's forge_token. A pinned integration that cannot resolve fails the tick loudly rather than limping to a doomed clone. Only a schedule with NO pinned integration falls back to the bot's forge_token secret resolution — per-bot binding first (POST /api/teams/{id}/bots/{bot}/bindings), else any team secret named forge_token — where a hand-set token that expires kills every tick at clone (Invalid username or token) while manual launches keep working. If a schedule is stuck in that state, recreate it with the repo attached (or bind the bot to the connection's managed secret).
  • A repo-bound schedule survives a re-provision. Re-provisioning the repo integration (enabling another bot, POST …/forge/repo-bots) deletes the integration's schedule rows and rebuilds one per kind: schedule invocation carrying a cron, but each surviving bot's row keeps its id (runs and audit entries point at it), the vars the operator set — merged over the manifest's schedule.default_vars, operator keys winning — its last fire, pause state, overlap/guard policy and customised cron. It does not keep stale_after, the keepalive interval, the pinned repo_ref, or the routing and retry overrides, and its last-run and last-error fields start empty. A row whose bot declares no schedule invocation with a cron — an operator-created row that the integration adopted because its repo_url matched — is deleted and not recreated. A bot whose scheduled behaviour hinges on a var should still declare it in default_vars, so a row created from scratch delivers too.

Implementation ​