Skip to content

scheduler: 5-field unix cron grammar + per-task tz (BREAKING)#21

Merged
cjus merged 1 commit into
mainfrom
carlos/solrac-cron-scheduler
May 15, 2026
Merged

scheduler: 5-field unix cron grammar + per-task tz (BREAKING)#21
cjus merged 1 commit into
mainfrom
carlos/solrac-cron-scheduler

Conversation

@cjus
Copy link
Copy Markdown
Owner

@cjus cjus commented May 15, 2026

Summary

BREAKING: TASK.md schedule: field is replaced by cron: (5-field unix cron) or at: (ISO8601), with optional per-task tz: (default: \$TZ env / host runtime tz). Adds cron-parser@5.5.0 exact-pinned to deps.

One grammar closes four real scheduler gaps in one change: time-of-day windows, weekday filtering, local-tz scheduling, and anchored cadence. The live trigger that motivated this — */30 12-18 * * 1-5 America/Denver for stretch reminders — required 13 separate daily_at TASK.md files under the old grammar.

Why (not what)

  • Operator needs hit the old grammar's ceiling. every <dur> drifts; daily_at HH:MM is UTC-only and one-shot; neither expresses "weekdays 12:00–18:30 my local tz."
  • cron-parser@5.5.0 handles tz + DST. Mature MIT lib, ~150 LOC of subtle DST math we don't write. Spring-forward skipped, fall-back single-fire — both unit-tested. Brings luxon transitively.
  • One-shape grammar. Our layer pre-validates 5-fields-only, rejects `@daily`/`@hourly` (cron-parser would accept them), and validates IANA tz via `Intl.DateTimeFormat` for clean errors. No "three composable fields" footgun zone.

Migration cheat sheet

Old `schedule:` New Notes
`every 1m` `cron: "* * * * *"` Ollama only (Claude floor 5m)
`every 5m` `cron: "*/5 * * * *"`
`every 30m` `cron: "*/30 * * * *"`
`every 1h` `cron: "0 * * * *"` Behavior change: anchored to :00, no drift
`every 2h` `cron: "0 */2 * * *"`
`every 1d` `cron: "0 0 * * *"`
`daily_at 09:00` (UTC) `cron: "0 9 * * *"` + `tz: UTC` `tz` required if host tz isn't UTC
`daily_at 09:00` (host tz) `cron: "0 9 * * *"` Uses host-tz default
`at 2026-06-01T09:00:00Z` `at: 2026-06-01T09:00:00Z` Field name change only

Full migration prose lives in `docs/USAGE.md` under "Migration from `schedule:` (pre-0.5.0)".

Behavior changes (call-outs for operators)

  • Anchored cadence. `0 * * * *` fires at `:00` of every hour. Old `every 1h` drifted from `last_run_at` — a mid-window restart at 14:13 would fire next at 15:13, not 15:00. Most operators want anchored; if you relied on drift, pin the minute (e.g. `13 * * * *`).
  • No first-deploy catch-up. A fresh task at 14:00 with `cron: "0 9 * * *"` waits until tomorrow 09:00 — not today's 09:00. Cron is anchored, not stateful. If you need a one-time-now fire, add a sibling `at:` task. Catch-up after restart (when `last_run_at` exists) still works — a missed window fires once at the next valid moment.

Live-verification fixes folded in

Two bugs surfaced during the dev-loop deploy:

  1. Tick driver anchor regression. `nextRunAt(task, lastRunAt=null, now)` for cron anchored on `now` — which advances every tick → `due` always future → fresh tasks never fired through the natural tick path (only `/tasks run ` worked). Fixed by threading `bootMs` through: cron anchors on `lastRunAt ?? bootMs ?? now`, with the tick driver passing `bootMs = bootTime`. New regression test simulates multi-tick clock advancement and asserts two fires across two cron moments — the existing tests missed this because they only exercised the boot tick.
  2. /tasks web-UI rendering. The listing was HTML-with-`\n` only; the web transport tried to render that as markdown and single `\n`s collapsed → everything on one line. Refactored to the dual-render pattern (`/help`, `/status`, `/context` already use it): authors markdown, sends `mdToTelegramHtml(md)` for the bot + raw `md` as `markdownSource` for the web. Telegram output virtually identical; web UI now renders properly.

Files touched

  • `package.json` / `package-lock.json` — `cron-parser@5.5.0` exact-pinned; version 0.4.0 → 0.5.0
  • `src/scheduler.ts` — new grammar, `tz` field, next-5-fires min-interval guard, bootMs anchor
  • `src/scheduler.test.ts` — 72 tests (was 60); adds tz/DST/weekday/min-interval/multi-tick coverage
  • `src/commands.ts` — `formatScheduleSpec` updated; `runTasksList` dual-render refactor; `formatNextFire` updated for new signature
  • `src/commands.test.ts` — fixture updates for new `Task` shape
  • `docs/USAGE.md` — full "Scheduled tasks" rewrite + migration cheat sheet + "Where the reply lands" subsection (DM vs group `chat_id` + silent-DM gotcha)
  • `docs/ARCHITECTURE.md`, `docs/GLOSSARY.md`, `docs/CONFIG.md`, `docs/OPERATIONS.md`, `docs/ROADMAP.md` — grammar updates + `TZ` env note + systemd `Environment=TZ=` recommendation
  • `examples/tasks/morning-digest/TASK.md`, `examples/tasks/weekly-pr-review/TASK.md` — canonical cron examples
  • `tasks/stretch/TASK.md` — removed (operator-specific; canonical examples now under `examples/tasks/`)
  • `CHANGELOG.md` — BREAKING entry

Anti-goal reversal

Reverses the "no cron in v1, kept the parser ~30 LOC" design note from `docs/ARCHITECTURE.md`. Justification lives in `PLAN.md § Adding cron-parser as a runtime dep`: replaces ~150 LOC of subtle tz/DST math we'd otherwise write, mature MIT lib, exact-pinned. Brings `luxon` transitively — earlier PLAN.md said "zero transitive deps" which is stale for v5.5.

Test plan

  • `npm run typecheck` clean
  • `bun test` green — 747/747 (72 scheduler tests, was 60)
  • DST smoke against `cron-parser@5.5.0` (spring-forward skip, fall-back single fire) — verified before adding the dep
  • Live: probe task (`* * * * *` UTC) fires every minute through the natural tick driver — confirmed after the bootMs anchor fix
  • Live: `/tasks run ` manual trigger fires immediately (queue + engine + Telegram send end-to-end)
  • Live: `/tasks` renders properly on both Telegram and web UI after the dual-render refactor
  • Live: full-window verification — stretch task firing at 12:00–18:30 weekdays Denver (waiting for the natural window; can verify next weekday inside 12:00 MDT)
  • Live: mid-window restart verifies anchored cadence — restart at e.g. 14:13 MDT, next fire is 14:30 (not 15:13)

breaking: TASK.md `schedule:` replaced by `cron:` or `at:` with optional
`tz:`. closes four scheduler gaps at once (time-of-day windows, weekday
filtering, local-tz scheduling, anchored cadence) via cron-parser@5.5.0
exact-pinned. migration cheat sheet in CHANGELOG.md + docs/USAGE.md.

behavior changes:
- anchored cadence: `0 * * * *` fires at :00, no drift from last_run_at
- no first-deploy catch-up: fresh tasks wait for the next cron moment
  (cron is anchored, not stateful)

two live-verification fixes folded in:
- tick driver anchors fresh cron tasks on bootTime (regression: anchoring
  on now meant `due` moved with the clock and the task never fired
  through the natural tick path)
- /tasks output uses the dual-render pattern (md -> telegram-html for
  the bot, raw md for the web) so the listing renders properly on
  both surfaces

747 tests pass. version 0.4.0 -> 0.5.0.
@cjus cjus merged commit e532203 into main May 15, 2026
1 check passed
@cjus cjus deleted the carlos/solrac-cron-scheduler branch May 15, 2026 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant