diff --git a/README.md b/README.md index aa1d2a097f..b8d11c4f88 100644 --- a/README.md +++ b/README.md @@ -2929,6 +2929,7 @@ Current MVP capabilities: - Manage tasks at `/workspace/scheduled-tasks` - One-time task forms reject local times skipped by daylight-saving transitions; select another time before creating or saving the task. +- Existing one-time tasks display the scheduler's execution time even when a conversation or API saved a local time skipped by daylight saving. Local times without an explicit offset use the first occurrence of a repeated hour. - Choose whether each scheduled task reuses a thread and its conversation history or creates a fresh thread per run - Pin each task to `lead_agent` (default) or a custom agent the owner already has; unknown names are rejected - Sending `assistant_id: null` in a scheduled-task PATCH resets the task to `lead_agent`; omitting `assistant_id` preserves the current agent, including when that custom agent has since been deleted diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 8420c7f7da..893332f769 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -207,6 +207,11 @@ UI validation separate from the API payload. Preserve the original instant when wall time and timezone match the mounted snapshot; validate changed inputs, and restore the exact original timestamp when those edits are reverted. +Wall-time conversion matches the scheduler's `ZoneInfo(fold=0)` policy: choose +the first occurrence of a repeated time and the pre-transition offset for a +skipped time. Stored one-time schedules use that instant for display; the form's +round-trip validation must still reject newly entered skipped times. + ## Resources - [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) diff --git a/frontend/src/core/scheduled-tasks/cron.ts b/frontend/src/core/scheduled-tasks/cron.ts index bc47e83158..23de102898 100644 --- a/frontend/src/core/scheduled-tasks/cron.ts +++ b/frontend/src/core/scheduled-tasks/cron.ts @@ -542,8 +542,9 @@ export function describeTaskSchedule( /** * Convert a datetime-local wall value (interpreted in `timezone`) to a UTC ISO - * string. The backend's `datetime.fromisoformat` treats naive strings as UTC, - * so we must submit an explicit offset. DST-safe via Intl. + * string. Match the scheduler's ZoneInfo(fold=0) reading: use the first + * occurrence of a repeated time and the pre-transition offset for a skipped + * time. Form validation rejects skipped times separately. */ export function zonedLocalToUtcIso( localValue: string, @@ -553,19 +554,20 @@ export function zonedLocalToUtcIso( const [y, mo, d] = (date ?? "").split("-").map(Number); const [h, mi] = (time ?? "00:00").split(":").map(Number); const refMs = Date.UTC(y ?? 1970, (mo ?? 1) - 1, d ?? 1, h ?? 0, mi ?? 0); - // The offset at the wall-time-as-UTC instant is stale when a DST transition - // sits between that instant and the real one (e.g. "03:30" the morning of - // spring-forward resolves with the pre-transition offset, firing an hour - // late and breaking the round-trip with `utcToZonedLocalInput`). Resolve - // once, then re-derive the offset at the candidate instant; one correction - // converges for real timezones. - let offsetMs = tzOffsetMs(timezone, new Date(refMs)); - let utcMs = refMs - offsetMs; - const correctedOffsetMs = tzOffsetMs(timezone, new Date(utcMs)); - if (correctedOffsetMs !== offsetMs) { - offsetMs = correctedOffsetMs; - utcMs = refMs - offsetMs; - } + // Look on both sides of a possible transition, including half-hour shifts + // and a skipped calendar day. Iterating one offset guess can oscillate in + // a gap or settle on the second occurrence of a repeated time. + const dayMs = 24 * 60 * 60 * 1000; + const before = tzOffsetMs(timezone, new Date(refMs - dayMs)); + const after = tzOffsetMs(timezone, new Date(refMs + dayMs)); + const candidates = [...new Set([refMs - before, refMs - after])].sort( + (a, b) => a - b, + ); + const utcMs = + candidates.find( + (candidate) => + candidate + tzOffsetMs(timezone, new Date(candidate)) === refMs, + ) ?? Math.max(...candidates); const utc = new Date(utcMs); return `${utc.getUTCFullYear()}-${pad2(utc.getUTCMonth() + 1)}-${pad2( utc.getUTCDate(), diff --git a/frontend/tests/e2e/scheduled-tasks.spec.ts b/frontend/tests/e2e/scheduled-tasks.spec.ts index 7cdffeb119..06efb659d6 100644 --- a/frontend/tests/e2e/scheduled-tasks.spec.ts +++ b/frontend/tests/e2e/scheduled-tasks.spec.ts @@ -788,6 +788,42 @@ test("reuse-thread option explains the queue behaviour in the dialog", async ({ await expect(dialog.getByRole("alert")).toHaveCount(0); }); +test("a stored DST gap shows the scheduled instant and a title edit preserves it", async ({ + page, +}) => { + await page.clock.setFixedTime(new Date("2026-08-01T00:00:00Z")); + const patches = writesTo(page, "PATCH", "/api/scheduled-tasks/dst-gap"); + await openPage(page, { + scheduledTasks: [ + task({ + id: "dst-gap", + title: "Midnight reminder", + schedule_type: "once", + schedule_spec: { run_at: "2026-09-06" }, + timezone: "America/Santiago", + next_run_at: "2026-09-06T04:00:00+00:00", + }), + ], + }); + // The server-provided next_run_at line was already correct on main. + // Check the schedule description itself, not any matching date in the panel. + await expect( + detail(page).getByText("Once, Sep 6 01:00 (America/Santiago)", { + exact: true, + }), + ).toBeVisible(); + await detail(page).getByRole("button", { name: "Edit" }).click(); + await expect(form(page).getByLabel("Run at")).toHaveValue("2026-09-06T01:00"); + await form(page) + .getByLabel("Title", { exact: true }) + .fill("Renamed reminder"); + await form(page).getByRole("button", { name: "Save changes" }).click(); + await expect(detail(page).getByRole("heading")).toHaveText( + "Renamed reminder", + ); + expect(patches).toEqual([{ title: "Renamed reminder" }]); +}); + for (const locale of ["en-US", "zh-CN"] as const) { test(`list and detail show no raw identifiers (${locale})`, async ({ page, diff --git a/frontend/tests/unit/components/workspace/schedule-edit-instant.dom.test.tsx b/frontend/tests/unit/components/workspace/schedule-edit-instant.dom.test.tsx index d51a76ed04..bae35114cc 100644 --- a/frontend/tests/unit/components/workspace/schedule-edit-instant.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/schedule-edit-instant.dom.test.tsx @@ -122,6 +122,28 @@ test("parent feedback and title-only rerenders retain the original instant", () ); }); +test("a stored skipped midnight opens at the scheduler's time without rewriting it", () => { + const initial = once("2026-09-06", "America/Santiago"); + const onChange = rs.fn(); + const ui = render( + , + ); + const input = ui.getByLabelText("Run at") as HTMLInputElement; + expect(input.value).toBe("2026-09-06T01:00"); + expect(onChange).toHaveBeenLastCalledWith(initial); + + fireEvent.change(input, { target: { value: "2026-09-06T02:00" } }); + expect(onChange).toHaveBeenLastCalledWith( + once("2026-09-06T05:00:00+00:00", "America/Santiago"), + ); + fireEvent.change(input, { target: { value: "2026-09-06T01:00" } }); + expect(onChange).toHaveBeenLastCalledWith(initial); +}); + test("changing timezone recomputes the instant and reverting restores seconds", () => { const initial = once("2027-06-01T12:30:45Z"); const onChange = rs.fn(); diff --git a/frontend/tests/unit/core/scheduled-tasks/cron.test.ts b/frontend/tests/unit/core/scheduled-tasks/cron.test.ts index 092c4d792f..0dcc0ed703 100644 --- a/frontend/tests/unit/core/scheduled-tasks/cron.test.ts +++ b/frontend/tests/unit/core/scheduled-tasks/cron.test.ts @@ -493,6 +493,21 @@ describe("utcToZonedLocalInput", () => { }); describe("zonedLocalToUtcIso DST transitions", () => { + // Expected instants match scheduler.next_run_at's ZoneInfo(fold=0) reading. + test.each([ + ["America/Santiago", "2026-09-06T00:00", "2026-09-06T04:00:00+00:00"], + ["America/Santiago", "2026-09-06T00:30", "2026-09-06T04:30:00+00:00"], + ["America/New_York", "2026-03-08T02:30", "2026-03-08T07:30:00+00:00"], + ["Europe/Berlin", "2026-03-29T02:30", "2026-03-29T01:30:00+00:00"], + ["Europe/Berlin", "2026-10-25T02:30", "2026-10-25T00:30:00+00:00"], + ["Australia/Lord_Howe", "2026-10-04T02:15", "2026-10-03T15:45:00+00:00"], + ["Australia/Lord_Howe", "2026-04-05T01:45", "2026-04-04T14:45:00+00:00"], + ["Pacific/Apia", "2011-12-30T12:00", "2011-12-30T22:00:00+00:00"], + ])("matches the scheduler at %s %s", (timezone, local, expected) => { + expect(zonedLocalToUtcIso(local, timezone)).toBe(expected); + expect(onceRunAtInstant(local, timezone)).toBe(expected); + }); + // US spring-forward 2026: clocks jump 02:00 -> 03:00 EST->EDT on 2026-03-08. test("New_York wall time after spring-forward uses the post-transition offset", () => { // 03:30 EDT (-4) is 07:30Z; the stale pre-transition offset (-5) would say 08:30Z. @@ -654,6 +669,19 @@ describe("describeTaskSchedule", () => { }); describe("describeTaskSchedule for a chat-created one-time task", () => { + test("a stored skipped midnight displays when the scheduler will run it", () => { + const task = { + schedule_type: "once" as const, + schedule_spec: { run_at: "2026-09-06" }, + timezone: "America/Santiago", + }; + expect( + describeTaskSchedule(task, "en-US", { + now: new Date("2026-08-01T00:00:00Z"), + }).text, + ).toBe("Once, Sep 6 01:00 (America/Santiago)"); + }); + test("reads the agent's local run_at in the task's zone, whatever the viewer's zone", () => { const task = { schedule_type: "once" as const, diff --git a/frontend/tests/unit/core/scheduled-tasks/valid-local-time.test.ts b/frontend/tests/unit/core/scheduled-tasks/valid-local-time.test.ts index aea90753ab..4ad41091ac 100644 --- a/frontend/tests/unit/core/scheduled-tasks/valid-local-time.test.ts +++ b/frontend/tests/unit/core/scheduled-tasks/valid-local-time.test.ts @@ -7,6 +7,7 @@ import { describe("one-time local time validation", () => { test.each([ + ["America/Santiago", "2026-09-06T00:30"], ["America/New_York", "2027-03-14T02:30"], ["Australia/Lord_Howe", "2027-10-03T02:15"], ["Pacific/Apia", "2011-12-30T12:00"], @@ -21,6 +22,7 @@ describe("one-time local time validation", () => { ["America/New_York", "2027-03-14T01:30", "2027-03-14T06:30:00+00:00"], ["America/New_York", "2027-03-14T03:30", "2027-03-14T07:30:00+00:00"], ["America/New_York", "2026-11-01T01:30", "2026-11-01T05:30:00+00:00"], + ["Europe/Berlin", "2026-10-25T02:30", "2026-10-25T00:30:00+00:00"], ["Australia/Lord_Howe", "2027-10-03T01:45", "2027-10-02T15:15:00+00:00"], ["Australia/Lord_Howe", "2027-10-03T02:45", "2027-10-02T15:45:00+00:00"], ["Asia/Shanghai", "2027-03-14T02:30", "2027-03-13T18:30:00+00:00"],