Skip to content

Timezone-aware cron schedules for reminders (CRON_TZ support) #1788

Description

@nixie-ai

Summary

Reminder cron schedules are always evaluated in UTC, and the common CRON_TZ= prefix (Vixie crontab syntax) fails to parse. Timezone-aware cron scheduling is needed so reminders like "every day at 09:00 local time" survive DST transitions without manual UTC re-conversion.

Reproduction

On current dev (Cronos 0.13.0, pinned in Directory.Packages.props), via dotnet-script against the exact pinned assembly:

CronExpression.Parse("CRON_TZ=Europe/Brussels 0 9 * * *", CronFormat.Standard)
→ CronFormatException: The given cron expression has an invalid format.
  Minutes: Value must be a number between 0 and 59 (all inclusive).

CronExpression.Parse("0 9 * * * Europe/Brussels", CronFormat.Standard)
→ CronFormatException: Unexpected character 'E'.

So both the prefix form and a trailing-zone 6-field form are rejected. This is expected — CRON_TZ= is crontab environment-variable syntax and Cronos has never parsed it in any released version.

The real blocker is not the parser, though: CronScheduleHelper.GetNextOccurrence hardcodes UTC:

// src/Netclaw.Actors/Reminders/CronScheduleHelper.cs
var expression = CronExpression.Parse(cronExpression, CronFormat.Standard);
return expression.GetNextOccurrence(from, TimeZoneInfo.Utc);

There is no timezone field anywhere in the reminder pipeline (ReminderSchedule, ReminderScheduleProto, set_reminder tool).

Key finding: the capability already exists in Cronos

Cronos is built with time zones in mind, and the overload works out of the box — verified against the pinned 0.13.0:

CronExpression.Parse("0 9 * * *").GetNextOccurrence(DateTimeOffset.UtcNow,
    TimeZoneInfo.FindSystemTimeZoneById("Europe/Brussels"))
→ 2026-08-07 07:00:00Z   (09:00 CEST, DST-correct)

So this is a small change: no new dependency, no new scheduling logic — netclaw just needs to stop hardcoding TimeZoneInfo.Utc.

Proposed change (happy to send a PR)

  1. CronScheduleHelper — accept an optional CRON_TZ=<tz-id> prefix (Vixie crontab syntax, which is what agents/users naturally try): strip it in TryParse/GetNextOccurrence, resolve via TimeZoneInfo.FindSystemTimeZoneById, and pass the zone to GetNextOccurrence(from, tz). Unknown zone → clear error. Default stays UTC (no behavior change for existing reminders).

  2. Storage — zero proto changes. ReminderManagerActor.ScheduleDefinitionAsync (the single funnel point for all cron scheduling/rescheduling, ReminderManagerActor.cs:965) re-parses the stored expression on every fire, so keeping the prefix inside the stored CronExpression string requires no ReminderScheduleProto/mapper changes. (Alternative: an optional time_zone_id proto field — cleaner schema, but touches netclaw_messages.proto + NetclawProtoMapper.)

  3. Touch pointsDescribe() hardcodes " UTC" in its output (CronScheduleHelper.cs), surfaced by ListRemindersTool/SetReminderTool; the ReminderScheduleParser validation error message; and the set_reminder tool description. All trivial.

  4. Tests — extend CronScheduleHelperTests: prefix parsing, DST spring-forward/fall-back transitions, invalid-zone rejection, plain-5-field regression; plus one ReminderManagerActorTests case asserting reschedule lands at the tz-correct UTC instant.

Roughly one small PR, confined to Netclaw.Actors/Reminders + tests.

Environment note

TimeZoneInfo.FindSystemTimeZoneById needs tzdata present in the container image (standard on Linux daemon deployments); worth a mention in docs/tests.

Current workaround

Convert the desired local time to UTC manually and re-adjust the reminder at each DST change.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions