Skip to content

Name a zone on every schedule and return when it runs next - #363

Merged
abhishekgahlot2 merged 1 commit into
mainfrom
routine-next-run-zones
Oct 18, 2026
Merged

abhishekgahlot2 merged 1 commit into
mainfrom
routine-next-run-zones

Conversation

@abhishekgahlot2

@abhishekgahlot2 abhishekgahlot2 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

The backend decides when a routine fires, but the API never said when the next run is, so a client had to rebuild the cron and timezone math itself. A schedule saved without a zone also ran on the server's local clock, which nobody could see. This change gives the backend one schedule engine, returns its next run on the API, and gives every schedule an explicit zone. Backend only; the web app is unchanged.

What changes:

  • One occurrence rule. backend/src/schedules/cron.ts gets nextOccurrence, the engine the hosted product runs, and isOccurrence, the same rule for a single minute. The tick fires a schedule in a minute isOccurrence names, unless it already fired it in that minute. schedules/repo.ts nextRunAt names the next such minute, and the API returns it: next_due_at on /api/automations (and /api/schedules), nextDueAt on /api/bots/:id/routines, and next_due_at in the agent's automation tools. It is null while a schedule is paused or when its cron names no date. A client can show the next run without computing it.
  • DST. Across a change, a fixed time (no * in the minute or hour field) now fires once, at the first reading of a time a fall-back repeats, and at the first minute after a time a spring-forward skips. Before, it fired twice in a fall-back and never in the gap. A periodic expression keeps real time. Lord Howe's half-hour change is covered.
  • Cost. The tick checks one minute per schedule, however rare its cron. A search skips the days a cron rules out using calendar arithmetic, so listing schedules with rare crons stays fast.
  • Every schedule names its zone. Migration 0114_schedule_zones writes the zone the backend's clock runs in into every schedule saved without one, so each keeps firing at the times it did, and makes the column NOT NULL. The pool passes that zone in as useagent.backend_time_zone. Applied without it (a scratch database, a tool), the migration refuses any schedule that needs a zone rather than guess one. The engine no longer reads a server-local clock, and a zone Intl cannot read names no run.
  • Release overlap. The previous backend serves until the swap and still saves a schedule sent without a zone with none; a trigger gives that write the same zone. The migration takes its table lock first, so it never deadlocks with a writer that already holds a row lock.
  • API. A create that sends no zone (an API client, an agent's tool call) runs in UTC, stored and returned; the web app already sends the creator's browser zone. A PATCH may change the zone, and one that names none (a client still holding a schedule from before the backfill sends null) leaves it as it is.

Tests (each failed before the change):

  • the tick across a fall-back, a spring-forward and Lord Howe's half-hour change;
  • the API's next run matching the minute the tick fires;
  • a run-now stamped in the next minute not hiding this minute's run;
  • isOccurrence matching nextOccurrence minute by minute across DST windows;
  • the tick and the list staying short with hundreds of rare-cron schedules;
  • a routine saved without a zone taking the zone of a backend whose clock reads Lord Howe time, then firing once across its fall-back;
  • the previous backend's zone-less writes;
  • the migration beside a writer holding a row lock (it deadlocked before the lock came first);
  • UTC on a zone-less create, and a zone-less PATCH keeping the zone.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

The backend decides when a routine fires, but the API never said when the
next run is, so a client had to rebuild the cron and timezone math itself,
and a schedule saved without a zone ran on the server's local clock, which
nobody could see.

The scheduler now fires by one occurrence rule, the engine the hosted
product runs (cron.ts nextOccurrence). The tick fires a schedule in a minute
that rule names (isOccurrence, one minute's check however rare the cron)
unless it fired it there already, and schedules/repo.ts nextRunAt names the
next such minute, which the automations API returns as next_due_at and a
bot's routines as nextDueAt. A search passes the days a cron rules out by
calendar arithmetic, so a rare cron costs a fraction of a millisecond rather
than a walk through years. Across a DST change a fixed time now fires once
at its first reading of a repeated hour and right after a skipped one, where
before it fired twice in a fall-back and never in a spring-forward gap.

Every schedule names its zone. Migration 0114 writes the zone the backend's
clock runs in (the pool names it in useagent.backend_time_zone) into every
schedule saved without one, so each keeps firing at the times it did, and
makes the column NOT NULL; applied without that setting it refuses rather
than guess. The previous backend serves until the swap and still saves a
schedule sent without a zone with none: a trigger gives that write the same
zone. The migration takes its table lock first, so it never deadlocks with
a writer that already holds a row lock. The engine no longer reads a
server-local clock, and a zone Intl cannot read names no run.

A create that sends no zone (an API client, an agent's tool call) runs in
UTC, stored and returned; the web app sends the creator's browser zone. A
PATCH may change the zone; one that names none (a client still holding a
schedule from before the backfill sends null) leaves it as it is.
@abhishekgahlot2
abhishekgahlot2 merged commit 9b31782 into main Oct 18, 2026
22 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 18, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants