Skip to content

[bug] A negatively-jittered cron run computes its next slot from the early fire time — "@daily" reliably fires twice in one night #412

Description

@serge-ivo

A negatively-jittered run makes the next slot minutes away, so "@daily" fires twice in one night

The mechanism

lib/triggers.ts:473, after a cron trigger runs:

next = applyJitter(nextRunAt(trigger.schedule, now, cfg.timezone), cfg.jitterMinutes);

now is the moment the sweep fired it — the jittered time, not the scheduled slot. And
applyJitter (:159) is symmetric:

const offsetMs = (Math.random() * 2 - 1) * j * 60_000;   // ±j minutes
const floor = Date.now() + 60_000;
return new Date(Math.max(Date.parse(iso) + offsetMs, floor)).toISOString();

So a run whose jitter landed negative happens before its slot, and the next slot is then computed
from that earlier moment — landing only minutes ahead instead of a day.

Concretely, with @daily and jitterMinutes: 90:

  1. run fires at 23:36 (slot 00:00, jitter −24m)
  2. nextRunAt("@daily", now = 23:36)00:00 tonight — 24 minutes away
  3. jitter ±90 around that, floored at now + 60s → next ∈ [23:37, 01:30]

Every outcome of step 3 is above the floor, so it does not merely risk running again — it always
does
, within about two hours. If that second run also lands before midnight the cycle repeats.

Live evidence, with a prediction you can check tonight

The one cron trigger on this account, GET /v1/triggers:

name        Daily friend sweep
schedule    @daily            jitterMinutes 90
action      run_browse        url https://www.facebook.com/friends/requests
lastRunAt   2026-08-08 00:16:13      ← slot 00:00, jitter +16m
nextRunAt   2026-08-08T23:36:00Z     ← computed from 00:16 → slot 00:00 Aug-9, jitter −24m
failureCount 0   lastError null

nextRunAt is before midnight. So when it fires at 23:36 tonight, step 2 computes the next slot
as Aug-9 00:00 — 24 minutes later — and it will browse Facebook again before ~01:30. Nothing is
broken-looking; failureCount stays 0 and no error is recorded, because each individual run
succeeds.

Why it matters more than a scheduling nicety

  • The action is run_browse against a real logged-in account. "Daily" that is sometimes twice-a-night
    is the mass-action-frequency class [browser][trust] Trust & permission model for browser agents (design prerequisite) #75 cares about, arriving through a bug rather than a policy.
  • It is silent and self-concealing: every run succeeds, so the only symptom is a workload the
    owner did not ask for and a lastRunAt that looks reasonable in isolation.
  • Every automatic run costs BYOK tokens and a browser session on the user's machine.

The cause, stated plainly

The trigger stores one time and uses it for two different things: when to fire (jittered) and
where we are in the schedule (the slot). Jitter is presentation; the slot is the state. Deriving
the next slot from the fire time lets presentation mutate state.

Fix

Compute the next slot from the SLOT, never from now. Keep the un-jittered scheduled time
alongside the jittered one — a next_slot_at column beside next_run_at, or store the slot and
apply jitter at dispatch — so:

slot(n+1) = nextRunAt(schedule, slot(n))        // pure cron, jitter-free
fire(n+1) = applyJitter(slot(n+1), jitter)      // presentation only

Then a −24m run still fires at 23:36 and the next slot is still Aug-9 00:00, a full day later.

Cheaper stop-gap if the column is unwelcome: compute from max(now, scheduledSlot) where the slot is
recoverable from nextRunAt minus the applied offset — but that means storing the offset, which is
the column again. The column is the honest version.

Also worth deciding: whether jitter should be symmetric at all. +0..j never moves a run into
the previous period and removes this class entirely; the anti-pattern-defence argument for jitter is
served just as well by a one-sided delay.

Acceptance

  • A run whose jitter is negative does not shorten the interval to the next run.
  • @daily fires once per day, whatever the jitter, with a test that steps a trigger through
    several negative-jitter runs and asserts the slot advances by exactly one period each time.
  • Existing rows recover on their next run rather than needing a backfill.

Files: workers/api/src/lib/triggers.ts:156-166,473, workers/api/src/routes/triggers.ts:255,316,
workers/api/src/lib/cron-time.ts (nextRunAt).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend / Worker / API workbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions