feat(web,hosted): the minute sweep fires due automation schedules, each once, read in their own time zone (automations 2/7)

Merged
DA
7d ago
+470-8

Automations 2/7, stacked on #2511: the schedule engine. The once-a-minute sweep now claims due schedules and queues a run for each one. Starting the coding agent for a queued run is 3/7, and nothing can create an automation until the API in 4/7, so in production this PR has nothing to fire yet.

Schedule engine

packages/hosted/src/automation-schedule.ts sits beside the wire, so the service and the desktop's "next run" read schedules the same way.

  • Reading: five-field cron, read in the automation's IANA zone through Effect's Cron. "9:00 on weekdays" in New York stays 9:00 there across the daylight-saving change.
  • Refusals, each a fixed word:
    • an expression that isn't five-field cron, including six-field cron with seconds;
    • an unknown zone;
    • a schedule that fires more often than every 15 minutes anywhere in its next 64 firings. Every firing starts a coding agent, so a once-a-minute cron would be a runaway.
  • Presets: SCHEDULE_PRESET (hourly, daily 09:00, weekdays 09:00, weekly Mon 09:00), for the picker.
  • Misfires: AUTOMATION_SCHEDULE.MISFIRE_GRACE_MS (1 h). A firing more than an hour late is moved on to its next time without a run.

Claim once

claimDueAutomations in the store runs in one transaction:

  1. select … where enabled and next_run_at <= now order by next_run_at limit 25 for update skip locked
  2. For each row, write the next due instant.
  3. If the row is within the grace window, record a queued run whose dedupe key is the due instant.

Vercel's cron can call the same minute twice (its docs say so). The second claim then finds each row either moved on or locked. A claim that still read the row at its old instant hits the run's unique key and records nothing. A backlog drains 25 per minute, soonest first.

hosted/automation-sweep.ts gives the sweep its sweepAutomations seam. The sweep's answer gains automations: { fired }, and the README documents the sweep and adds an Automations section.

Tests

  • packages/hosted/src/automation-schedule.test.ts:
    • weekdays at 9:00 in New York across the 2026-11-01 change;
    • "next" is strictly after the given instant;
    • refusals for prose, six-field cron, an unknown zone, * * * * *, and 0,5 9 * * * (a 5-minute gap);
    • */15 passes.
  • automation-store.test.ts, on PGlite:
    • a due schedule is claimed once and moved on;
    • a second claim finds nothing;
    • a schedule 3 h stale is moved on with no run;
    • a later one and a paused one are left alone;
    • a claim that read the old instant records no second run.
  • hosted-maintenance-sweep.test.ts: a refused sweep fires no automations.

pnpm check:affected passes. There are no UI changes in this PR, so there is no Mac evidence and verify.sh was not run.

šŸ¤– Generated with Claude Code


Open workspace in Conductor


Devin Review


Summary by cubic

Adds the schedule engine for automations: the once-a-minute sweep now claims due schedules and queues a run for each one, exactly once. Nothing can create an automation yet (the API lands in a later step), so nothing fires in production.

Schedules are five-field cron read in the automation's IANA zone, so "9:00 on weekdays" in New York stays 9:00 there across daylight-saving changes. The claim runs in one transaction: it locks due rows with skip locked, writes each one's next due instant, and records a queued run under the due instant, whose dedupe key holds it to once even if Vercel's cron calls the same minute twice. A firing more than an hour late is moved on without a run, so a backlog drains 25 per minute, soonest first, and is never replayed. Only schedules are claimed, and only those of owners who still reach their plans.

Also

  • Refuses non-five-field cron, unknown zones, and schedules that fire more often than every 15 minutes anywhere in their next 64 firings.
  • Adds SCHEDULE_PRESET (hourly, daily 09:00, weekdays 09:00, weekly Mon 09:00) for the picker.