ADR-0040: Schedule trigger (cron / every, timezone, run-now)¶
Status: accepted (KAN-1856)
Context¶
agent.trigger was webhook-only. Unattended workflows (a nightly ETL) need a run on a clock, driven by the
unattended runner (ADR-0028, KAN-1855) so they get its overlap lock, backoff and fail-visible behaviour.
Decision¶
- Shape.
trigger: { type: schedule, cron | every, tz?, overlap? }, version 1.1 only. Exactly one ofcron(five fields) orevery(30s,15m,6h,1d, at least1s).tzis an IANA zone, defaultUTC, valid only withcron.overlapisskip(default) orqueue. The trigger object is strict, so a typo (timezone) is an error. Content errors (bad cron field, unknown zone) carry the line of the field. - No cron dependency; hand-rolled, about 300 lines,
schedule.ts. The reasons: the exporter vendors runtime files and pins every runtime dependency (ADR-0010), the spec validator must apply the exact rules the engine runs (a library would be a second runtime dependency of@kampong/spec, which has none that touch time), and the tricky part (DST) is the part libraries disagree on, so we own and test it. Time zone data comes from the platform'sIntl, not a bundled database.packages/spec/src/schedule.tsis a byte-identical copy of the engine's, enforced by a test; the exporter's parity test covers the vendored copy. - Cron semantics.
*, lists, ranges,a-b/n,*/n, month and weekday names, 7 as Sunday; if both day-of-month and day-of-week are restricted either may match. An expression that can never match a real date (0 0 30 2 *) is rejected. - DST. A wall-clock time that does not exist runs once, at the first instant after the gap (matches inside
one gap collapse into that run). A wall-clock time that happens twice runs once, at its first occurrence. A
schedule that must tick on every real hour or N minutes regardless of the calendar should use
every, which counts elapsed time from the momentkampong servestarted. - Overlap. The runner runs one job at a time.
skipdrops ticks that came due during a run and waits for the next one.queuekeeps at most one: however many ticks passed, one run starts as soon as the current one ends. A second process on the same state store is held off by the runner's lock, which the pacing hook (RunnerOptions.nextDelayMs, new) leaves intact. A run that pauses for approval counts as finished for the tick (the approval routes own it); a failed run is a job failure (backoff, then the failure limit). - Run-now.
POST /schedule/run-nowonkampong servestarts a run immediately. 404: no schedule trigger. 409: the lock is held (a scheduled tick or an earlier run-now is in progress); a manual run is refused, never queued, whateveroverlapsays. 503: the scheduler is not running. 201 with the run id otherwise. - Scheduled run input is the empty string; there is no payload to read as
trigger.*.
Consequences¶
- An export of a schedule spec vendors the scheduling code. Initially it did not wire a scheduler into its
generated
src/server.ts; KAM-158 did: the server starts the same runner (serve-runner.ts, hand-vendored from the CLI) and servesPOST /schedule/run-now, and the generated README saysnpm run serveis the scheduled entry andnpm starta single run. - The lock TTL is
max(5 x interval, 15 minutes)and is not renewed: a run longer than that can overlap a second process's tick. - The canvas form for the trigger is KAN-1850.