Files
open-design/apps/daemon/tests/routines.test.ts
Tom HuangandGitHub 643d0cf637 feat: add scheduled routines for unattended agent runs (#1033)
* feat: add scheduled routines for unattended agent runs

Generalizes Orbit's single hard-coded daily-digest scheduler into
user-defined routines: each one fires on a schedule (hourly / daily /
weekdays / weekly with IANA timezone) and starts a fresh agent
conversation, either inside an existing project or in a new project
minted on the spot.

Backend:
- New RoutineService with timezone-aware nextRunAt computed via
  Intl.DateTimeFormat (no new dependency); two-pass tzWallToUtc so
  DST transitions stay correct. Each fire chains rescheduleOne in
  finally() to keep the cadence alive.
- routines + routine_runs SQLite tables; schedule_json is the
  authoritative form, with legacy schedule_kind/value kept populated.
- /api/routines CRUD + /api/routines/:id/run + /api/routines/:id/runs.
- Run handler resolves agent (routine override -> app config -> first
  available), creates project (or reuses configured one) and a fresh
  conversation per fire, then dispatches into startChatRun.

UI (Settings -> Routines):
- Pill-chip schedule kind picker, time + timezone fields, weekday
  picker for Weekly. Live preview line ("Runs daily at 9:00 AM GMT+8").
- Routine list with inline status pill, next/last meta, expandable run
  history; each history row links into the project the run wrote to
  via the existing router primitive.

* fix(daemon): swallow trailing finally rejection for inflight cleanup

Without a terminal `.catch`, the promise returned by `promise.finally(...)`
mirrors the original rejection and produces an unhandled rejection — fatal
in modern Node — when the run handler rejects before producing a start
handle. Callers still see the rejection on the returned `promise`.

Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)

* fix(daemon): handle DST spring-forward gap in tzWallToUtc

The two-pass conversion picked the pre-gap candidate when the requested
wall time fell inside a spring-forward gap (e.g. 02:30 in
America/New_York on 2026-03-08), so the resulting instant rendered back
as 01:30 local and a 02:30 routine fired an hour early on the
transition day. Routines are local wall-clock schedules, so firing
before the requested time breaks the contract.

Now we round-trip both candidates through partsInTimezone, return the
one whose wall-clock matches the request, and on a gap day where
neither matches return the later candidate so the routine fires at the
first valid post-gap instant on the same day.

Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)

* fix(daemon): preserve both wall-time candidates on DST fall-back day

On a fall-back day, the requested wall time inside the repeated hour
(e.g. 01:30 America/New_York) maps to two distinct UTC instants. The
previous tzWallToUtc collapsed them to the first (pre-transition) one,
so a daemon that woke between the two instants would skip the second
01:30 entirely and fire a day late once per fall-back. Replace it with
tzWallToUtcCandidates (returns all valid instants, ascending) plus a
gap-only fallback for spring-forward, and have nextWallTimeMatching
walk both ambiguous candidates before advancing to the next day. Adds
fixtures for the repeated-hour case so the intended behavior stays
locked in.

Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)

* fix(web): make routine timezone picker IANA-complete and DST-truthful

The timezone dropdown was a hardcoded subset, but the backend validator
accepts any IANA zone — so users could not pick zones like
`America/Phoenix` or `Africa/Johannesburg` unless they happened to be
local. And `gmtLabel()` always derived the offset from `new Date()`,
which drifted seasonally for DST-observing zones (a New York routine
created in winter rendered `GMT-5` while it would actually fire on
`GMT-4` after DST started).

Source the picker from `Intl.supportedValuesOf('timeZone')` (with a
curated fallback for older runtimes) and anchor the GMT label to the
routine's next fire time. When the next fire time is unknown (e.g.
the live preview while the form is open) and in the dropdown itself,
fall back to the IANA city, which is stable year-round.

Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)

* fix(web): always include UTC in routine timezone picker

`Intl.supportedValuesOf('timeZone')` returns only canonical region
names on current runtimes (Node 24, recent browsers) and omits `UTC`,
so the previous picker dropped the most common non-local zone unless
the runtime itself was already UTC. The backend validator and the
contract examples still accept `UTC`, so a user on a non-UTC machine
could not create a documented UTC routine from Settings.

Prepend `UTC` inside `listSupportedTimezones()` when the runtime list
omits it, so the picker stays aligned with the supported schedule
surface.

Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)
2026-05-09 19:30:22 +08:00

118 lines
4.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { nextRunAtForSchedule } from '../src/routines.js';
function partsIn(timezone: string, at: Date): Record<string, string> {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
const out: Record<string, string> = {};
for (const part of dtf.formatToParts(at)) {
if (part.type !== 'literal') out[part.type] = part.value;
}
if (out.hour === '24') out.hour = '00';
return out;
}
describe('nextRunAtForSchedule DST handling', () => {
it('does not fire before the requested wall time on a spring-forward gap day', () => {
// 2026-03-08 in America/New_York: clocks jump 02:00 EST → 03:00 EDT, so
// a daily routine scheduled at 02:30 has no valid wall clock that day.
// Prior to the fix, tzWallToUtc returned 06:30Z which renders back as
// 01:30 EST — an hour before the requested time. The fixed scheduler
// must instead advance to a valid post-gap instant on the same day.
const now = new Date('2026-03-08T05:00:00Z');
const next = nextRunAtForSchedule(
{ kind: 'daily', time: '02:30', timezone: 'America/New_York' },
now,
);
expect(next).not.toBeNull();
if (!next) return;
const parts = partsIn('America/New_York', next);
expect(parts.year).toBe('2026');
expect(parts.month).toBe('03');
expect(parts.day).toBe('08');
const wallMinutes = Number(parts.hour) * 60 + Number(parts.minute);
expect(wallMinutes).toBeGreaterThanOrEqual(2 * 60 + 30);
});
it('still fires the second occurrence when the wall time itself is in the repeated hour', () => {
// 2026-11-01 in America/New_York: 01:30 happens twice — first at
// 05:30Z (EDT) and again at 06:30Z (EST) after clocks fall back.
// If the daemon checks at 05:45Z (between the two occurrences),
// a daily routine at 01:30 must still fire today at 06:30Z, not
// skip to 2026-11-02 because the EDT instance is already past.
const now = new Date('2026-11-01T05:45:00Z');
const next = nextRunAtForSchedule(
{ kind: 'daily', time: '01:30', timezone: 'America/New_York' },
now,
);
expect(next).not.toBeNull();
if (!next) return;
expect(next.getTime()).toBe(Date.UTC(2026, 10, 1, 6, 30));
const parts = partsIn('America/New_York', next);
expect(parts.year).toBe('2026');
expect(parts.month).toBe('11');
expect(parts.day).toBe('01');
expect(parts.hour).toBe('01');
expect(parts.minute).toBe('30');
});
it('returns the first occurrence in the repeated hour when now is before either instance', () => {
// Before 05:30Z on the fall-back day, the next 01:30 NY is the
// first (EDT) occurrence at 05:30Z.
const now = new Date('2026-11-01T05:00:00Z');
const next = nextRunAtForSchedule(
{ kind: 'daily', time: '01:30', timezone: 'America/New_York' },
now,
);
expect(next).not.toBeNull();
if (!next) return;
expect(next.getTime()).toBe(Date.UTC(2026, 10, 1, 5, 30));
});
it('selects the post-fall-back instance on a fall-back day with ambiguous wall times', () => {
// 2026-11-01 in America/New_York: 01:30 happens twice (EDT and EST).
// For a daily routine at 02:30, the only valid instance is 02:30 EST,
// which renders to 07:30Z. Make sure we pick that one regardless of
// candidate ordering inside tzWallToUtc.
const now = new Date('2026-11-01T05:00:00Z');
const next = nextRunAtForSchedule(
{ kind: 'daily', time: '02:30', timezone: 'America/New_York' },
now,
);
expect(next).not.toBeNull();
if (!next) return;
const parts = partsIn('America/New_York', next);
expect(parts.year).toBe('2026');
expect(parts.month).toBe('11');
expect(parts.day).toBe('01');
expect(parts.hour).toBe('02');
expect(parts.minute).toBe('30');
});
it('returns the requested wall time on non-transition days', () => {
const now = new Date('2026-05-01T00:00:00Z');
const next = nextRunAtForSchedule(
{ kind: 'daily', time: '02:30', timezone: 'America/New_York' },
now,
);
expect(next).not.toBeNull();
if (!next) return;
const parts = partsIn('America/New_York', next);
expect(parts.hour).toBe('02');
expect(parts.minute).toBe('30');
});
});