The scheduler decides when, never what. It holds a cron expression and a job definition. When the expression matches it hands the job to the dispatcher. It has no execution logic of its own.
#Schedules and cron expressions
npx tsx orchestrator/cli.ts schedule add jobdef.json --cron "*/5 * * * *" --name invoices
npx tsx orchestrator/cli.ts schedule list
npx tsx orchestrator/cli.ts schedule remove <id>
The expression is standard five-field cron: minute, hour, day of month, month, day of week. 0 is Sunday. A field accepts *, a step (*/5), a list (1,15,30), a range (9-17) and a bare number. The expression is validated when the schedule is added, so a bad cron fails at schedule time rather than at the first tick. Matching uses the orchestrator process clock and host-local time; there is no timezone conversion.
Each schedule record carries its job, its expression, an enabled flag, a nextDueAt and a single most-recent outcome rather than a history. nextDueAt is computed at registration and again when an outcome is recorded.
#The dispatch loop
npx tsx orchestrator/cli.ts scheduler run --once
npx tsx orchestrator/cli.ts scheduler run --poll-ms 30000
One pass ticks the schedule store for everything due right now, then dispatches each due job. A tick marks a schedule fired for the current minute, so repeated ticks inside one minute do not double-fire it. The single most-recent outcome is one of dispatched (the row was created and handed to the dispatcher) or dispatch-failed (the seam or dispatcher refused before Robot execution). The seam never watches for a Robot's terminal outcome, because that happens after the frozen boundary.
A tick lock serialises the critical section per state root. It is a create-exclusive lock file, scheduler-tick.lock, held while the tick marks fires and pre-creates a job row for every due schedule, then released before any Robot runs. When a second ticker finds the lock held it exits with code 3 and creates no row. A lock older than its stale window is stolen from a crashed holder.
Before each tick the pass reconciles any fire an earlier pass marked but never told. A fire with no row behind it has its mark withdrawn. A fire with a row is adopted as that row's outcome, with any anomaly stated rather than hidden. This is what stops a completed run from being reported as overdue.
#Reading schedule health
A schedule is never-run, ok, or overdue. It is overdue when an occurrence has fully elapsed with no dispatch outcome for it. There is no catch-up: a missed window does not shift nextDueAt. The schedule simply fires at its next matching minute.
#Things that catch people out
scheduler run calls the governed seam, the same one the server uses, so its dispatcher dependencies include the deployment store. A label schedule therefore needs an activation registered against this state root. Those activations come through the server rather than the CLI. A manual orchestrator run uses the ungoverned path and does not.
Reconciliation runs before the tick, so a pass cannot stack a new fire on an unreconciled one.
The default poll interval is 30000 milliseconds. --once runs a single pass and exits, which is the shape a cron-driven ticker uses.