PR #3514
Sections
Review

fix(office): correct cron DOM/DOW OR semantics, DST fire policy, and unsatisfiable expressions

main ← feature/office-cron-dom-dow-go2 13 files +487 −62 PR #3514 ↗

Cron triggers now use OR for DOM/DOW, fire once across DST, reject impossible dates at create time, and default timezone to UTC with a backfill.

Why this change

Cron triggers fire on the wrong days, fire twice or skip during DST, accept impossible dates like Feb 30, and store an empty timezone that breaks scheduling.

What it does

Architecture, end to end

A cron trigger flows from HTTP create through validation and storage, then the tick loop computes the next fire with the corrected engine.

flowchart LR
  Client[Client / UI] --> Handler[routines Handler]
  Handler --> Service[RoutineService]
  Service --> Cron[shared.NextCronTime]
  Cron --> Store[(office_routine_triggers)]
  Store --> Ticker[TickScheduledTriggers]
  Ticker --> Cron
  Ticker --> Dispatch[DispatchRoutineRun]
  Dispatch --> Wakeup[(agent_wakeup_requests)]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

Cron engine: DOM/DOW OR, DST, and unsatisfiable detectionapps/backend/internal/office/shared/cron.go ↗
func NextCronTime(expression, timezone string, after time.Time) (time.Time, error)
Click for details →

The engine now matches crontab(5) OR semantics and enforces at-most-once DST firing for every IANA zone.

NextCronTime core loop
func NextCronTime(expression, timezone string, after time.Time) (time.Time, error) {
  loc, err := resolveLocation(timezone)
  if err != nil {
    return time.Time{}, err
  }
  trimmed := strings.TrimSpace(expression)
  if len(strings.Fields(trimmed)) != 5 {
    return time.Time{}, fmt.Errorf("parse cron expression: %q: must be exactly 5 whitespace-separated fields", expression)
  }
  schedule, err := cronParser.Parse(trimmed)
  if err != nil {
    return time.Time{}, fmt.Errorf("parse cron expression: %w", err)
  }
  specSchedule, ok := schedule.(*cron.SpecSchedule)
  if !ok {
    return time.Time{}, fmt.Errorf("internal: unexpected schedule type %T", schedule)
  }
  start := after.In(loc)
  candidate := schedule.Next(start)
  if candidate.IsZero() {
    return time.Time{}, fmt.Errorf("%w: %q", ErrUnsatisfiableCron, expression)
  }
  for isAmbiguousFallBack(candidate) || !matchesWallClock(specSchedule, candidate) {
    candidate = schedule.Next(candidate)
    if candidate.IsZero() {
      return time.Time{}, fmt.Errorf("%w: %q", ErrUnsatisfiableCron, expression)
    }
  }
  if earlier, ok := findEarlierMatchAcrossSubHourTransition(specSchedule, loc, start, candidate); ok {
    candidate = earlier
  }
  return candidate.UTC(), nil
}
DOM/DOW OR logic
func dayMatches(spec *cron.SpecSchedule, t time.Time) bool {
  const starBit = 1 << 63
  domMatch := 1<<uint(t.Day())&spec.Dom > 0
  dowMatch := 1<<uint(t.Weekday())&spec.Dow > 0
  if spec.Dom&starBit > 0 || spec.Dow&starBit > 0 {
    return domMatch && dowMatch
  }
  return domMatch || dowMatch
}
Fall-back suppression
func isAmbiguousFallBack(candidate time.Time) bool {
  start, _ := candidate.ZoneBounds()
  if start.IsZero() {
    return false
  }
  _, currentOffset := candidate.Zone()
  _, priorOffset := start.Add(-time.Second).Zone()
  if priorOffset <= currentOffset {
    return false
  }
  repeatedWindow := time.Duration(priorOffset-currentOffset) * time.Second
  return candidate.Before(start.Add(repeatedWindow))
}
Trigger create validates expression and defaults timezoneapps/backend/internal/office/routines/service.go ↗
func (s *RoutineService) CreateRoutineTrigger(ctx context.Context, t *RoutineTrigger) error
Click for details →

Empty or impossible cron expressions are rejected at create time instead of becoming silent no-ops.

CreateRoutineTrigger
func (s *RoutineService) CreateRoutineTrigger(ctx context.Context, t *RoutineTrigger) error {
  if t.Timezone == "" {
    t.Timezone = "UTC"
  }
  if t.Kind == "cron" {
    if t.CronExpression == "" {
      return fmt.Errorf("%w: cron trigger requires a cron_expression", ErrInvalidTrigger)
    }
    next, err := shared.NextCronTime(t.CronExpression, t.Timezone, time.Now().UTC())
    if err != nil {
      return fmt.Errorf("%w: invalid cron expression: %v", ErrInvalidTrigger, err)
    }
    t.NextRunAt = &next
  }
  return s.repo.CreateRoutineTrigger(ctx, t)
}
Sentinel error
var ErrInvalidTrigger = errors.New("invalid routine trigger")
Tick loop disarms unsatisfiable triggers, re-arms recoverable onesapps/backend/internal/office/routines/service.go ↗
func (s *RoutineService) processCronTrigger(ctx context.Context, trigger *RoutineTrigger, now time.Time) error
Click for details →

An impossible expression no longer re-arms to now and loops forever; transient failures retry next tick.

processCronTrigger error branch
  runCount, advanceTo, err := computeRoutineMissed(trigger, routine, now)
  if err != nil {
    if errors.Is(err, shared.ErrUnsatisfiableCron) {
      s.logger.Error("cron expression unsatisfiable; trigger permanently disarmed",
        zap.String("trigger_id", trigger.ID), zap.Error(err))
      return err
    }
    if rearmErr := s.repo.UpdateTriggerNextRun(ctx, trigger.ID, trigger.NextRunAt); rearmErr != nil {
      s.logger.Warn("re-arm trigger after recoverable catch-up failure failed",
        zap.String("trigger_id", trigger.ID), zap.Error(rearmErr))
    }
    s.logger.Warn("compute routine catch-up failed; will retry next tick",
      zap.String("trigger_id", trigger.ID), zap.Error(err))
    return err
  }
Handler maps validation errors to 400apps/backend/internal/office/routines/handler.go ↗
func (h *Handler) createTrigger(c *gin.Context)
Click for details →

Clients receive a 400 for bad cron input instead of a 500 that hides a user error.

createTrigger status mapping
func (h *Handler) createTrigger(c *gin.Context) {
  var req CreateTriggerRequest
  if err := c.ShouldBindJSON(&req); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
  }
  trigger := &RoutineTrigger{
    RoutineID:      c.Param("id"),
    Kind:           req.Kind,
    CronExpression: req.CronExpression,
    Timezone:       req.Timezone,
    PublicID:       req.PublicID,
    SigningMode:    req.SigningMode,
    Secret:         req.Secret,
    Enabled:        true,
  }
  if err := h.svc.CreateRoutineTrigger(c.Request.Context(), trigger); err != nil {
    status := http.StatusInternalServerError
    if errors.Is(err, ErrInvalidTrigger) {
      status = http.StatusBadRequest
    }
    c.JSON(status, gin.H{"error": err.Error()})
    return
  }
  trigger.Secret = ""
  c.JSON(http.StatusCreated, TriggerResponse{Trigger: trigger})
}
Storage defaults timezone to UTC and backfills legacy rowsapps/backend/internal/office/repository/sqlite/base.go ↗
func (r *Repository) backfillRoutineTriggerTimezones()
Click for details →

New triggers store UTC explicitly and old empty timezones are migrated so reads never see an empty value.

Schema default
CREATE TABLE IF NOT EXISTS office_routine_triggers (
  id TEXT PRIMARY KEY,
  routine_id TEXT NOT NULL,
  kind TEXT NOT NULL,
  cron_expression TEXT DEFAULT '',
  timezone TEXT DEFAULT 'UTC',
  public_id TEXT DEFAULT '',
  signing_mode TEXT DEFAULT '',
  secret TEXT DEFAULT '',
  next_run_at TIMESTAMP,
  last_fired_at TIMESTAMP,
  enabled INTEGER DEFAULT 1,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL,
  FOREIGN KEY (routine_id) REFERENCES office_routines(id) ON DELETE CASCADE
);
Backfill migration
func (r *Repository) backfillRoutineTriggerTimezones() {
  if _, err := r.db.Exec(
    `UPDATE office_routine_triggers SET timezone = 'UTC' WHERE kind = 'cron' AND timezone = ''`,
  ); err != nil && r.log != nil {
    r.log.Warn("routine trigger timezone backfill failed", zap.Error(err))
  }
}
Read the changes as a list

Cron engine: DOM/DOW OR, DST, and unsatisfiable detection

apps/backend/internal/office/shared/cron.go

The engine now matches crontab(5) OR semantics and enforces at-most-once DST firing for every IANA zone.

NextCronTime core loop
func NextCronTime(expression, timezone string, after time.Time) (time.Time, error) {
  loc, err := resolveLocation(timezone)
  if err != nil {
    return time.Time{}, err
  }
  trimmed := strings.TrimSpace(expression)
  if len(strings.Fields(trimmed)) != 5 {
    return time.Time{}, fmt.Errorf("parse cron expression: %q: must be exactly 5 whitespace-separated fields", expression)
  }
  schedule, err := cronParser.Parse(trimmed)
  if err != nil {
    return time.Time{}, fmt.Errorf("parse cron expression: %w", err)
  }
  specSchedule, ok := schedule.(*cron.SpecSchedule)
  if !ok {
    return time.Time{}, fmt.Errorf("internal: unexpected schedule type %T", schedule)
  }
  start := after.In(loc)
  candidate := schedule.Next(start)
  if candidate.IsZero() {
    return time.Time{}, fmt.Errorf("%w: %q", ErrUnsatisfiableCron, expression)
  }
  for isAmbiguousFallBack(candidate) || !matchesWallClock(specSchedule, candidate) {
    candidate = schedule.Next(candidate)
    if candidate.IsZero() {
      return time.Time{}, fmt.Errorf("%w: %q", ErrUnsatisfiableCron, expression)
    }
  }
  if earlier, ok := findEarlierMatchAcrossSubHourTransition(specSchedule, loc, start, candidate); ok {
    candidate = earlier
  }
  return candidate.UTC(), nil
}
DOM/DOW OR logic
func dayMatches(spec *cron.SpecSchedule, t time.Time) bool {
  const starBit = 1 << 63
  domMatch := 1<<uint(t.Day())&spec.Dom > 0
  dowMatch := 1<<uint(t.Weekday())&spec.Dow > 0
  if spec.Dom&starBit > 0 || spec.Dow&starBit > 0 {
    return domMatch && dowMatch
  }
  return domMatch || dowMatch
}
Fall-back suppression
func isAmbiguousFallBack(candidate time.Time) bool {
  start, _ := candidate.ZoneBounds()
  if start.IsZero() {
    return false
  }
  _, currentOffset := candidate.Zone()
  _, priorOffset := start.Add(-time.Second).Zone()
  if priorOffset <= currentOffset {
    return false
  }
  repeatedWindow := time.Duration(priorOffset-currentOffset) * time.Second
  return candidate.Before(start.Add(repeatedWindow))
}

Trigger create validates expression and defaults timezone

apps/backend/internal/office/routines/service.go

Empty or impossible cron expressions are rejected at create time instead of becoming silent no-ops.

CreateRoutineTrigger
func (s *RoutineService) CreateRoutineTrigger(ctx context.Context, t *RoutineTrigger) error {
  if t.Timezone == "" {
    t.Timezone = "UTC"
  }
  if t.Kind == "cron" {
    if t.CronExpression == "" {
      return fmt.Errorf("%w: cron trigger requires a cron_expression", ErrInvalidTrigger)
    }
    next, err := shared.NextCronTime(t.CronExpression, t.Timezone, time.Now().UTC())
    if err != nil {
      return fmt.Errorf("%w: invalid cron expression: %v", ErrInvalidTrigger, err)
    }
    t.NextRunAt = &next
  }
  return s.repo.CreateRoutineTrigger(ctx, t)
}
Sentinel error
var ErrInvalidTrigger = errors.New("invalid routine trigger")

Tick loop disarms unsatisfiable triggers, re-arms recoverable ones

apps/backend/internal/office/routines/service.go

An impossible expression no longer re-arms to now and loops forever; transient failures retry next tick.

processCronTrigger error branch
  runCount, advanceTo, err := computeRoutineMissed(trigger, routine, now)
  if err != nil {
    if errors.Is(err, shared.ErrUnsatisfiableCron) {
      s.logger.Error("cron expression unsatisfiable; trigger permanently disarmed",
        zap.String("trigger_id", trigger.ID), zap.Error(err))
      return err
    }
    if rearmErr := s.repo.UpdateTriggerNextRun(ctx, trigger.ID, trigger.NextRunAt); rearmErr != nil {
      s.logger.Warn("re-arm trigger after recoverable catch-up failure failed",
        zap.String("trigger_id", trigger.ID), zap.Error(rearmErr))
    }
    s.logger.Warn("compute routine catch-up failed; will retry next tick",
      zap.String("trigger_id", trigger.ID), zap.Error(err))
    return err
  }

Handler maps validation errors to 400

apps/backend/internal/office/routines/handler.go

Clients receive a 400 for bad cron input instead of a 500 that hides a user error.

createTrigger status mapping
func (h *Handler) createTrigger(c *gin.Context) {
  var req CreateTriggerRequest
  if err := c.ShouldBindJSON(&req); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
  }
  trigger := &RoutineTrigger{
    RoutineID:      c.Param("id"),
    Kind:           req.Kind,
    CronExpression: req.CronExpression,
    Timezone:       req.Timezone,
    PublicID:       req.PublicID,
    SigningMode:    req.SigningMode,
    Secret:         req.Secret,
    Enabled:        true,
  }
  if err := h.svc.CreateRoutineTrigger(c.Request.Context(), trigger); err != nil {
    status := http.StatusInternalServerError
    if errors.Is(err, ErrInvalidTrigger) {
      status = http.StatusBadRequest
    }
    c.JSON(status, gin.H{"error": err.Error()})
    return
  }
  trigger.Secret = ""
  c.JSON(http.StatusCreated, TriggerResponse{Trigger: trigger})
}

Storage defaults timezone to UTC and backfills legacy rows

apps/backend/internal/office/repository/sqlite/base.go

New triggers store UTC explicitly and old empty timezones are migrated so reads never see an empty value.

Schema default
CREATE TABLE IF NOT EXISTS office_routine_triggers (
  id TEXT PRIMARY KEY,
  routine_id TEXT NOT NULL,
  kind TEXT NOT NULL,
  cron_expression TEXT DEFAULT '',
  timezone TEXT DEFAULT 'UTC',
  public_id TEXT DEFAULT '',
  signing_mode TEXT DEFAULT '',
  secret TEXT DEFAULT '',
  next_run_at TIMESTAMP,
  last_fired_at TIMESTAMP,
  enabled INTEGER DEFAULT 1,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL,
  FOREIGN KEY (routine_id) REFERENCES office_routines(id) ON DELETE CASCADE
);
Backfill migration
func (r *Repository) backfillRoutineTriggerTimezones() {
  if _, err := r.db.Exec(
    `UPDATE office_routine_triggers SET timezone = 'UTC' WHERE kind = 'cron' AND timezone = ''`,
  ); err != nil && r.log != nil {
    r.log.Warn("routine trigger timezone backfill failed", zap.Error(err))
  }
}

Data and storage

office_routine_triggers now stores an explicit UTC timezone; empty is no longer a valid state.

FieldTypeNotes
timezoneTEXT DEFAULT 'UTC'was DEFAULT '' ; backfilled to UTC for cron triggers
cron_expressionTEXTrejected if empty or unsatisfiable at create time
next_run_atTIMESTAMPcomputed via NextCronTime; cleared on claim, disarmed if unsatisfiable

Risk

5 / 10 Medium
1 low5 medium10 high

Why this score

  • Cron is the sole periodic wake path; a regression skips or duplicates coordinator heartbeats.
  • DST and Lord Howe logic touches every IANA zone; covered by new sweep tests but still time-sensitive.
  • Migration backfills live rows; it is idempotent and cron-only but runs on every boot.

Trade-offs and review notes

Where to look first

  1. Verify dayMatches OR vs AND and the starBit handling in shared/cron.go.
  2. Check NextCronTime DST guards: isAmbiguousFallBack, matchesWallClock, and Lord Howe rescan.
  3. Confirm CreateRoutineTrigger rejects empty and unsatisfiable expressions and defaults timezone to UTC.
  4. Confirm processCronTrigger disarms on ErrUnsatisfiableCron and re-arms on recoverable errors.
  5. Check handler maps ErrInvalidTrigger to 400 and backfill touches only cron rows with empty timezone.