Scheduler — cadence to fired run [Flow]
From the Meridians Wiki · Public · Maintained · joint
The headless Program scheduler is the time-based orchestration layer: it reads enabled schedules from the record, arms Croner timers, and on each tick runs a Research/Opinion/Merge/Position operation to completion — under concurrency, serialization, and spend gates. The pure when-to-fire logic is separate from the imperative shell that does the waking.
flowchart TB
Rec[("record — enabled schedules<br/>(days · hours · everyNWeeks)")]
Meta[("META — **baseline operations timezone**<br/>(aux/meta.json · one instance-wide IANA zone)")]
Rec -->|"reconcile() · per-document fingerprint + compact projection<br/>read only the source that changed"| Arm
Meta -->|"all cadences interpreted in it"| Arm
subgraph Arm["arm — pure WHEN → live timers"]
direction TB
Cron["scheduleToCron(schedule)<br/>(future windows only — no boot replay)"]
Timer["new Cron(pattern, tz, onFire)<br/>diff vs live timers: add new · drop stale"]
Cron --> Timer
end
Timer -->|"tick"| Fire
subgraph Fire["onFire — the gates"]
direction TB
Week{"weekGatePasses?<br/>(fortnightly/monthly — cron can't express)"}
Spend{"spend cap?<br/>(provider-spending jobs only)"}
Serial{"domain already running?<br/>(per-domain serialization)"}
Conc{"concurrency slot?<br/>(global cap, default 3)"}
Week -->|"no"| Skip1(["skip until due"])
Week -->|"yes"| Spend -->|"blocked"| Skip2(["skip — cap reached"])
Spend -->|"ok"| Serial -->|"busy"| Queue(["queue — one run per domain"])
Serial -->|"free"| Conc -->|"full"| Wait(["wait for a slot"])
Conc -->|"slot"| Run
end
Run["runOperationToEnd — the Research/Opinion/Merge/Position op<br/>(coalesced cycleIds if cadence matches)"]
Run -->|"same op machinery"| Ops(["operation-lifecycle → commits + echo"])
Invariants
- Croner wakes; the record decides. The scheduler holds no schedule authority of its own — it derives timers from enabled schedules. The 30-second
reconcile()poll checks cheap per-document sidecar/body-stat fingerprints and retains only each source's compact job projection, so one canonical write re-reads one Domain/Constellation rather than the whole record; incomplete reads are never cached. Timers fire in future windows only; there is no boot replay of missed runs. - One baseline timezone, all cadences relative. A cadence stores only its wall-clock day/hour; the ONE instance-wide baseline operations timezone (persisted in the record's META, Director-set in Settings) interprets every cadence at once. Croner fires the local hour in the baseline zone → the exact UTC instant; changing the baseline re-relativizes every schedule with no per-domain rewrite. Hydration seeds it from META, else the operator's browser zone. Cadence editors and the agenda render local-primary + UTC-secondary.
- The everyNWeeks gate is checked at fire time. Cron can't express fortnightly/monthly, so
weekGatePasses(schedule, lastRunAt, now)gates it in the shell. Research cycles sharing a schedule coalesce into one Watch pass (days/hours sorted → order-independent). - Three discipline gates, all independent. Per-domain serialization (at most one mutating run per domain), a global concurrency cap (default 3), and a per-instance weekly spend ceiling that gates only provider-spending jobs (syncs are exempt). Failures are isolated — one domain's error never stalls another.
- Only always-on hosts arm it.
isAlwaysOnServer()gates the scheduler, reconcile loop, and journal drain (platform-model); serverless hosts run no timers.
Where it lives: src/lib/server/program/scheduler.ts (imperative shell — timers + gates), src/lib/core/program/schedule-cron.ts (pure scheduleToCron · weekGatePasses · nextFireAt, all baseline-zone-aware), src/lib/core/program/operations-timezone.ts (the baseline resolver: get/set/subscribe, validated), src/lib/server/record/instance-usage.ts (spend estimate). The run itself: operation-lifecycle · maintenance-run. Timing rules owned by src/lib/core/program/schedule-cron.ts.