diff --git a/docs/data-model.md b/docs/data-model.md index 78c9a993f..2287935bd 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -206,7 +206,7 @@ One file per schedule. ID is 8 hex characters. Writes are direct (not atomic). ### Nested: ScheduleCadence (discriminated union on `type`) - `{ type: "every", everyMs: number }` — interval in milliseconds -- `{ type: "cron", expression: string }` — cron expression +- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence ### Nested: ScheduleTarget (discriminated union on `type`) diff --git a/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts b/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts index c8a9bef74..cdabe417a 100644 --- a/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts @@ -144,19 +144,19 @@ describe("terminal resize reflow repro (Paseo terminal)", () => { it("snapshot-restored rows stay frozen at the snapshot width after the terminal grows", async () => { await page.viewport(1600, 700); const m = mount(560, 360); // ~70 cols - await waitFor(() => m.terminal.cols > 0); + await waitFor(() => m.terminal.cols > 40); const narrowCols = m.terminal.cols; // 1) Mid-stream snapshot arrives (server overflowed 256KB). It carries the // long line wrapped at the server width as separate grid rows. await renderSnapshotCommitted(m.runtime, buildWrappedSnapshot(narrowCols)); - await waitFor(() => someRowContains(m.terminal, '"seq":1')); + await waitFor(() => someRowContains(m.terminal, "[15:30:00.123]")); // 2) Normal post-snapshot streaming resumes (autowrap on -> soft-wrapped). for (let seq = 90; seq <= 96; seq++) { await writeCommitted(m.runtime, encodeTerminalOutput(pinoLine(seq))); } - await waitFor(() => someRowContains(m.terminal, '"seq":96')); + await waitFor(() => someRowContains(m.terminal, "[15:30:36.123]")); // 3) User resizes the terminal wider. m.root.style.width = "1480px"; diff --git a/packages/cli/src/commands/schedule/create.ts b/packages/cli/src/commands/schedule/create.ts index c0e47ce74..378e44675 100644 --- a/packages/cli/src/commands/schedule/create.ts +++ b/packages/cli/src/commands/schedule/create.ts @@ -13,6 +13,7 @@ import { export interface ScheduleCreateOptions extends ScheduleCommandOptions { every?: string; cron?: string; + timezone?: string; name?: string; target?: string; provider?: string; @@ -34,6 +35,7 @@ export async function runCreateCommand( prompt, every: options.every, cron: options.cron, + timezone: options.timezone, name: options.name, target: options.target, provider: options.provider, diff --git a/packages/cli/src/commands/schedule/index.ts b/packages/cli/src/commands/schedule/index.ts index 54b48555f..5087db18a 100644 --- a/packages/cli/src/commands/schedule/index.ts +++ b/packages/cli/src/commands/schedule/index.ts @@ -21,6 +21,7 @@ export function createScheduleCommand(): Command { .argument("", "Prompt to run on the schedule") .option("--every ", "Fixed interval cadence (for example: 5m, 1h)") .option("--cron ", "Cron cadence expression") + .option("--timezone ", "IANA time zone for cron cadence (default: UTC)") .option("--name ", "Optional schedule name") .option("--target ", "Run target") .option( @@ -82,6 +83,7 @@ export function createScheduleCommand(): Command { .argument("", "Schedule ID") .option("--every ", "Switch to fixed interval cadence (for example: 5m, 1h)") .option("--cron ", "Switch to cron cadence expression") + .option("--timezone ", "IANA time zone for cron cadence (requires --cron)") .option("--name ", "Rename the schedule (empty string clears the name)") .option("--prompt ", "Replace the schedule prompt") .option( diff --git a/packages/cli/src/commands/schedule/schema.ts b/packages/cli/src/commands/schedule/schema.ts index bf3383d73..16c2a6ea8 100644 --- a/packages/cli/src/commands/schedule/schema.ts +++ b/packages/cli/src/commands/schedule/schema.ts @@ -1,5 +1,5 @@ import type { OutputSchema } from "../../output/index.js"; -import { formatTarget, type ScheduleRow } from "./shared.js"; +import { formatCadence, formatTarget, type ScheduleRow } from "./shared.js"; import type { ScheduleRecord, ScheduleRunRecord } from "./types.js"; export const scheduleSchema: OutputSchema = { @@ -73,7 +73,7 @@ export function createScheduleInspectRows(schedule: ScheduleRecord): ScheduleIns key: "Cadence", value: schedule.cadence.type === "cron" - ? `cron:${schedule.cadence.expression}` + ? formatCadence(schedule.cadence) : `every:${schedule.cadence.everyMs}ms`, }, { key: "Target", value: formatTarget(schedule.target) }, diff --git a/packages/cli/src/commands/schedule/shared.test.ts b/packages/cli/src/commands/schedule/shared.test.ts index 7cd2db79a..e483e71aa 100644 --- a/packages/cli/src/commands/schedule/shared.test.ts +++ b/packages/cli/src/commands/schedule/shared.test.ts @@ -88,6 +88,19 @@ describe("parseScheduleCreateInput first-run timing", () => { expect(input.runOnCreate).toBe(true); }); + test("--cron with --timezone stores a timezone-aware cadence", () => { + const input = parseScheduleCreateInput({ + ...baseCron, + timezone: " America/New_York ", + }); + + expect(input.cadence).toEqual({ + type: "cron", + expression: "0 9 * * *", + timezone: "America/New_York", + }); + }); + test("--every with --run-now is rejected as redundant", () => { expect(() => parseScheduleCreateInput({ ...baseOptions, runNow: true })).toThrow( expect.objectContaining({ @@ -105,6 +118,15 @@ describe("parseScheduleCreateInput first-run timing", () => { }), ); }); + + test("--timezone without --cron is rejected", () => { + expect(() => parseScheduleCreateInput({ ...baseOptions, timezone: "Europe/Zurich" })).toThrow( + expect.objectContaining({ + code: "INVALID_TIME_ZONE", + message: "--timezone can only be used with --cron", + }), + ); + }); }); describe("parseScheduleUpdateInput", () => { @@ -151,12 +173,31 @@ describe("parseScheduleUpdateInput", () => { }); }); + test("parses --cron cadence with --timezone", () => { + expect( + parseScheduleUpdateInput({ + id: "abc", + cron: "30 9 * * *", + timezone: "Europe/Zurich", + }), + ).toEqual({ + id: "abc", + cadence: { type: "cron", expression: "30 9 * * *", timezone: "Europe/Zurich" }, + }); + }); + test("rejects passing both --every and --cron", () => { expect(() => parseScheduleUpdateInput({ id: "abc", every: "5m", cron: "0 9 * * *" })).toThrow( expect.objectContaining({ code: "INVALID_CADENCE" }), ); }); + test("rejects --timezone without --cron", () => { + expect(() => parseScheduleUpdateInput({ id: "abc", timezone: "Europe/Zurich" })).toThrow( + expect.objectContaining({ code: "INVALID_TIME_ZONE" }), + ); + }); + test("parses provider/model shorthand and explicit mode", () => { expect( parseScheduleUpdateInput({ diff --git a/packages/cli/src/commands/schedule/shared.ts b/packages/cli/src/commands/schedule/shared.ts index 0c610666b..643037c06 100644 --- a/packages/cli/src/commands/schedule/shared.ts +++ b/packages/cli/src/commands/schedule/shared.ts @@ -49,7 +49,8 @@ export function toScheduleCommandError(code: string, action: string, error: unkn export function formatCadence(cadence: ScheduleCadence): string { if (cadence.type === "cron") { - return `cron:${cadence.expression}`; + const timezoneSuffix = cadence.timezone ? ` (${cadence.timezone})` : ""; + return `cron:${cadence.expression}${timezoneSuffix}`; } return `every:${formatDurationMs(cadence.everyMs)}`; } @@ -129,6 +130,7 @@ export function parseScheduleCreateInput(options: { prompt: string; every?: string; cron?: string; + timezone?: string; name?: string; target?: string; provider?: string; @@ -147,7 +149,7 @@ export function parseScheduleCreateInput(options: { } satisfies CommandError; } - const cadence = parseCadenceFromFlags(options.every, options.cron); + const cadence = parseCadenceFromFlags(options.every, options.cron, options.timezone); if (!cadence) { throw { code: "INVALID_CADENCE", @@ -232,6 +234,7 @@ export interface ScheduleUpdateOptionsInput { id: string; every?: string; cron?: string; + timezone?: string; name?: string; prompt?: string; provider?: string; @@ -253,7 +256,7 @@ export function parseScheduleUpdateInput(options: ScheduleUpdateOptionsInput): U } satisfies CommandError; } - const cadence = parseCadenceFromFlags(options.every, options.cron); + const cadence = parseCadenceFromFlags(options.every, options.cron, options.timezone); const newAgentConfig = buildNewAgentConfigPatch(options); const maxRuns = parseUpdateMaxRuns(options); const expiresAt = parseUpdateExpiresAt(options); @@ -288,6 +291,7 @@ export function parseScheduleUpdateInput(options: ScheduleUpdateOptionsInput): U function parseCadenceFromFlags( every: string | undefined, cron: string | undefined, + timezone: string | undefined, ): ScheduleCadence | undefined { if (every !== undefined && cron !== undefined) { throw { @@ -295,15 +299,40 @@ function parseCadenceFromFlags( message: "Specify at most one of --every or --cron", } satisfies CommandError; } + const trimmedTimeZone = parseTimeZoneFlag(timezone); + if (trimmedTimeZone !== undefined && cron === undefined) { + throw { + code: "INVALID_TIME_ZONE", + message: "--timezone can only be used with --cron", + } satisfies CommandError; + } if (every !== undefined) { return { type: "every", everyMs: parseDuration(every) }; } if (cron !== undefined) { - return { type: "cron", expression: cron.trim() }; + return { + type: "cron", + expression: cron.trim(), + ...(trimmedTimeZone ? { timezone: trimmedTimeZone } : {}), + }; } return undefined; } +function parseTimeZoneFlag(timeZone: string | undefined): string | undefined { + if (timeZone === undefined) { + return undefined; + } + const trimmed = timeZone.trim(); + if (!trimmed) { + throw { + code: "INVALID_TIME_ZONE", + message: "--timezone cannot be empty", + } satisfies CommandError; + } + return trimmed; +} + function parseUpdateMaxRuns(options: ScheduleUpdateOptionsInput): number | null | undefined { if (options.maxRuns !== undefined && options.clearMaxRuns) { throw { diff --git a/packages/cli/src/commands/schedule/types.ts b/packages/cli/src/commands/schedule/types.ts index c6722ae04..9d67e4819 100644 --- a/packages/cli/src/commands/schedule/types.ts +++ b/packages/cli/src/commands/schedule/types.ts @@ -8,6 +8,7 @@ export type ScheduleCadence = | { type: "cron"; expression: string; + timezone?: string; }; export type ScheduleTarget = diff --git a/packages/cli/src/commands/schedule/update.ts b/packages/cli/src/commands/schedule/update.ts index 159092de9..5b62590fd 100644 --- a/packages/cli/src/commands/schedule/update.ts +++ b/packages/cli/src/commands/schedule/update.ts @@ -15,6 +15,7 @@ import { export interface ScheduleUpdateOptions extends ScheduleCommandOptions { every?: string; cron?: string; + timezone?: string; name?: string; prompt?: string; provider?: string; @@ -36,6 +37,7 @@ export async function runUpdateCommand( id, every: options.every, cron: options.cron, + timezone: options.timezone, name: options.name, prompt: options.prompt, provider: options.provider, diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index d1ee7af04..a43ca6e09 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -568,6 +568,7 @@ export interface CreateScheduleOptions { | { type: "cron"; expression: string; + timezone?: string; }; target: | { @@ -623,6 +624,7 @@ export interface UpdateScheduleOptions { | { type: "cron"; expression: string; + timezone?: string; }; newAgentConfig?: UpdateScheduleNewAgentConfig; maxRuns?: number | null; diff --git a/packages/protocol/src/schedule/types.test.ts b/packages/protocol/src/schedule/types.test.ts new file mode 100644 index 000000000..afeed7c90 --- /dev/null +++ b/packages/protocol/src/schedule/types.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest"; + +import { ScheduleCadenceSchema } from "./types.js"; + +describe("ScheduleCadenceSchema", () => { + test("accepts existing UTC cron cadence without a time zone", () => { + expect(ScheduleCadenceSchema.parse({ type: "cron", expression: "0 9 * * *" })).toEqual({ + type: "cron", + expression: "0 9 * * *", + }); + }); + + test("accepts timezone-aware cron cadence", () => { + expect( + ScheduleCadenceSchema.parse({ + type: "cron", + expression: "0 9 * * *", + timezone: "America/New_York", + }), + ).toEqual({ + type: "cron", + expression: "0 9 * * *", + timezone: "America/New_York", + }); + }); +}); diff --git a/packages/protocol/src/schedule/types.ts b/packages/protocol/src/schedule/types.ts index dc3468c99..44129f77a 100644 --- a/packages/protocol/src/schedule/types.ts +++ b/packages/protocol/src/schedule/types.ts @@ -12,6 +12,7 @@ export const ScheduleCadenceSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("cron"), expression: z.string().trim().min(1), + timezone: z.string().trim().min(1).optional(), }), ]); export type ScheduleCadence = z.infer; diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 73c9ee227..9b99fa07c 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -2328,6 +2328,38 @@ describe("create_schedule MCP tool", () => { ); }); + it("passes timezone through cron create_schedule input", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const create = vi.fn(async (scheduleInput: CreateScheduleInput) => + createStoredSchedule(scheduleInput), + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + scheduleService: { create } as unknown as ScheduleService, + logger, + }); + const tool = registeredTool(server, "create_schedule"); + + await invokeToolWithParsedInput(tool, { + prompt: "say hello", + cron: "0 9 * * 1-5", + timezone: " America/New_York ", + provider: "codex", + }); + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + cadence: { + type: "cron", + expression: "0 9 * * 1-5", + timezone: "America/New_York", + }, + }), + ); + }); + it("still rejects both real every and cron inputs", async () => { const { agentManager, agentStorage } = createTestDeps(); const create = vi.fn(); @@ -2352,6 +2384,54 @@ describe("create_schedule MCP tool", () => { expect(create).not.toHaveBeenCalled(); }); + it("rejects create_schedule timezone without cron", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const create = vi.fn(); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + scheduleService: { create } as unknown as ScheduleService, + logger, + }); + const tool = registeredTool(server, "create_schedule"); + + await expect( + invokeToolWithParsedInput(tool, { + prompt: "say hello", + every: "10m", + timezone: "America/New_York", + provider: "codex", + }), + ).rejects.toThrow("timezone can only be used with cron"); + + expect(create).not.toHaveBeenCalled(); + }); + + it.each(["", " "])("rejects create_schedule blank timezone %#", async (timezone) => { + const { agentManager, agentStorage } = createTestDeps(); + const create = vi.fn(); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + scheduleService: { create } as unknown as ScheduleService, + logger, + }); + const tool = registeredTool(server, "create_schedule"); + + await expect( + invokeToolWithParsedInput(tool, { + prompt: "say hello", + cron: "0 9 * * 1-5", + timezone, + provider: "codex", + }), + ).rejects.toThrow(); + + expect(create).not.toHaveBeenCalled(); + }); + it.each([ { label: "missing both cadence fields", @@ -2458,6 +2538,35 @@ describe("update_schedule MCP tool", () => { }); }); + it("passes timezone through cron update_schedule input", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const stored = makeStoredSchedule(); + const update = vi.fn(async (_input: UpdateScheduleInput) => stored); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + scheduleService: { update } as unknown as ScheduleService, + logger, + }); + const tool = registeredTool(server, "update_schedule"); + + await invokeToolWithParsedInput(tool, { + id: "schedule-1", + cron: "0 9 * * 1-5", + timezone: "Europe/Zurich", + }); + + expect(update).toHaveBeenCalledWith({ + id: "schedule-1", + cadence: { + type: "cron", + expression: "0 9 * * 1-5", + timezone: "Europe/Zurich", + }, + }); + }); + it("accepts a blank cron field when updating every cadence", async () => { const { agentManager, agentStorage } = createTestDeps(); const stored = makeStoredSchedule(); @@ -2542,6 +2651,52 @@ describe("update_schedule MCP tool", () => { expect(update).not.toHaveBeenCalled(); }); + it("rejects update_schedule timezone without cron", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const update = vi.fn(); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + scheduleService: { update } as unknown as ScheduleService, + logger, + }); + const tool = registeredTool(server, "update_schedule"); + + await expect( + invokeToolWithParsedInput(tool, { + id: "schedule-1", + every: "10m", + timezone: "Europe/Zurich", + }), + ).rejects.toThrow("timezone can only be used with cron"); + + expect(update).not.toHaveBeenCalled(); + }); + + it.each(["", " "])("rejects update_schedule blank timezone %#", async (timezone) => { + const { agentManager, agentStorage } = createTestDeps(); + const update = vi.fn(); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + scheduleService: { update } as unknown as ScheduleService, + logger, + }); + const tool = registeredTool(server, "update_schedule"); + + await expect( + invokeToolWithParsedInput(tool, { + id: "schedule-1", + cron: "0 9 * * 1-5", + timezone, + }), + ).rejects.toThrow(); + + expect(update).not.toHaveBeenCalled(); + }); + it("passes new-agent config and expiry updates", async () => { const { agentManager, agentStorage } = createTestDeps(); const stored = makeStoredSchedule(); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 5df22c48b..4d4e1ccaa 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -333,6 +333,7 @@ interface ScheduleUpdateToolInput { id: string; every?: string; cron?: string; + timezone?: string; name?: string | null; prompt?: string; maxRuns?: number | null; @@ -357,18 +358,30 @@ function normalizeScheduleCadenceArg(value: string | undefined): string | undefi return trimmed; } +function normalizeScheduleTimeZoneArg(value: string | undefined): string | undefined { + return normalizeScheduleCadenceArg(value); +} + function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleCadence | undefined { const every = normalizeScheduleCadenceArg(input.every); const cron = normalizeScheduleCadenceArg(input.cron); + const timeZone = normalizeScheduleTimeZoneArg(input.timezone); if (every !== undefined && cron !== undefined) { throw new Error("Specify at most one of every or cron"); } + if (timeZone !== undefined && cron === undefined) { + throw new Error("timezone can only be used with cron"); + } if (every !== undefined) { return { type: "every", everyMs: parseDurationString(every) }; } if (cron !== undefined) { - return { type: "cron", expression: cron }; + return { + type: "cron", + expression: cron, + ...(timeZone !== undefined ? { timezone: timeZone } : {}), + }; } return undefined; } @@ -1583,6 +1596,14 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom prompt: z.string().trim().min(1, "prompt is required"), every: z.string().optional(), cron: z.string().optional(), + timezone: z + .string() + .trim() + .min(1) + .optional() + .describe( + "IANA time zone for cron cadence; requires cron. For example: America/New_York.", + ), name: z.string().optional(), target: z.enum(["self", "new-agent"]).optional(), provider: AgentProviderEnum.optional().describe( @@ -1594,18 +1615,22 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, outputSchema: ScheduleSummarySchema.shape, }, - async ({ prompt, every, cron, name, target, provider, cwd, maxRuns, expiresIn }) => { + async ({ prompt, every, cron, timezone, name, target, provider, cwd, maxRuns, expiresIn }) => { if (!scheduleService) { throw new Error("Schedule service is not configured"); } const normalizedEvery = normalizeScheduleCadenceArg(every); const normalizedCron = normalizeScheduleCadenceArg(cron); + const normalizedTimeZone = normalizeScheduleTimeZoneArg(timezone); const cadenceCount = Number(normalizedEvery !== undefined) + Number(normalizedCron !== undefined); if (cadenceCount !== 1) { throw new Error("Specify exactly one of every or cron"); } + if (normalizedTimeZone !== undefined && normalizedCron === undefined) { + throw new Error("timezone can only be used with cron"); + } const scheduleTarget = target === "self" @@ -1643,7 +1668,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom cadence: normalizedEvery !== undefined ? { type: "every" as const, everyMs: parseDurationString(normalizedEvery) } - : { type: "cron" as const, expression: normalizedCron! }, + : { + type: "cron" as const, + expression: normalizedCron!, + ...(normalizedTimeZone !== undefined ? { timezone: normalizedTimeZone } : {}), + }, target: scheduleTarget, ...(name?.trim() ? { name: name.trim() } : {}), ...(maxRuns === undefined ? {} : { maxRuns }), @@ -1792,6 +1821,14 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom id: z.string(), every: z.string().optional().describe("New interval duration string (e.g. 5m, 1h)."), cron: z.string().optional().describe("New cron expression."), + timezone: z + .string() + .trim() + .min(1) + .optional() + .describe( + "IANA time zone for cron cadence; requires cron. For example: America/New_York.", + ), name: z.string().nullable().optional().describe("New name (null to clear)."), prompt: z.string().trim().min(1).optional().describe("New prompt text."), maxRuns: z diff --git a/packages/server/src/server/schedule/cron.test.ts b/packages/server/src/server/schedule/cron.test.ts index af9418e20..3d0b27cd5 100644 --- a/packages/server/src/server/schedule/cron.test.ts +++ b/packages/server/src/server/schedule/cron.test.ts @@ -20,9 +20,47 @@ describe("schedule cron cadence", () => { expect(next.toISOString()).toBe("2026-01-05T09:15:00.000Z"); }); + test("computes timezone cron matches at the requested wall-clock time", () => { + const winter = computeNextRunAt( + { type: "cron", expression: "0 9 * * 1-5", timezone: "America/New_York" }, + new Date("2026-01-05T13:59:30.000Z"), + ); + const summer = computeNextRunAt( + { type: "cron", expression: "0 9 * * 1-5", timezone: "America/New_York" }, + new Date("2026-07-06T12:59:30.000Z"), + ); + + expect(winter.toISOString()).toBe("2026-01-05T14:00:00.000Z"); + expect(summer.toISOString()).toBe("2026-07-06T13:00:00.000Z"); + }); + + test("keeps repeated fall-back wall-clock matches distinct", () => { + const first = computeNextRunAt( + { type: "cron", expression: "30 1 1 11 *", timezone: "America/New_York" }, + new Date("2026-11-01T05:29:30.000Z"), + ); + const second = computeNextRunAt( + { type: "cron", expression: "30 1 1 11 *", timezone: "America/New_York" }, + first, + ); + + expect(first.toISOString()).toBe("2026-11-01T05:30:00.000Z"); + expect(second.toISOString()).toBe("2026-11-01T06:30:00.000Z"); + }); + test("rejects invalid cron expressions", () => { expect(() => validateScheduleCadence({ type: "cron", expression: "not-a-valid-cron" })).toThrow( "Cron expressions must have 5 fields", ); }); + + test("rejects invalid cron time zones", () => { + expect(() => + validateScheduleCadence({ + type: "cron", + expression: "0 9 * * *", + timezone: "Not/AZone", + }), + ).toThrow("Invalid cron time zone: Not/AZone"); + }); }); diff --git a/packages/server/src/server/schedule/cron.ts b/packages/server/src/server/schedule/cron.ts index 10ca641ef..cba2f97fc 100644 --- a/packages/server/src/server/schedule/cron.ts +++ b/packages/server/src/server/schedule/cron.ts @@ -81,6 +81,14 @@ interface ParsedCronExpression { dayOfWeek: CronFieldMatcher; } +interface CronDateParts { + minute: number; + hour: number; + dayOfMonth: number; + month: number; + dayOfWeek: number; +} + function parseCronExpression(expression: string): ParsedCronExpression { const parts = expression.trim().split(/\s+/); if (parts.length !== 5) { @@ -110,9 +118,65 @@ function startOfNextMinute(date: Date): Date { ); } +function assertValidTimeZone(timeZone: string): void { + try { + new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date(0)); + } catch { + throw new Error(`Invalid cron time zone: ${timeZone}`); + } +} + +function createCronDatePartsReader(timeZone: string | undefined): (date: Date) => CronDateParts { + if (timeZone === undefined) { + return (date: Date) => ({ + minute: date.getUTCMinutes(), + hour: date.getUTCHours(), + dayOfMonth: date.getUTCDate(), + month: date.getUTCMonth() + 1, + dayOfWeek: date.getUTCDay(), + }); + } + + assertValidTimeZone(timeZone); + + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + + return (date: Date) => { + const values: Record = {}; + for (const part of formatter.formatToParts(date)) { + if (part.type !== "literal") { + values[part.type] = part.value; + } + } + + const year = Number.parseInt(values.year, 10); + const month = Number.parseInt(values.month, 10); + const dayOfMonth = Number.parseInt(values.day, 10); + + return { + minute: Number.parseInt(values.minute, 10), + hour: Number.parseInt(values.hour, 10), + dayOfMonth, + month, + dayOfWeek: new Date(Date.UTC(year, month - 1, dayOfMonth)).getUTCDay(), + }; + }; +} + export function validateScheduleCadence(cadence: ScheduleCadence): void { if (cadence.type === "cron") { parseCronExpression(cadence.expression); + if (cadence.timezone !== undefined) { + assertValidTimeZone(cadence.timezone); + } } } @@ -122,15 +186,12 @@ export function computeNextRunAt(cadence: ScheduleCadence, after: Date): Date { } const cron = parseCronExpression(cadence.expression); + const readDateParts = createCronDatePartsReader(cadence.timezone); const limit = 366 * 24 * 60; let cursor = startOfNextMinute(after); for (let index = 0; index < limit; index += 1) { - const minute = cursor.getUTCMinutes(); - const hour = cursor.getUTCHours(); - const dayOfMonth = cursor.getUTCDate(); - const month = cursor.getUTCMonth() + 1; - const dayOfWeek = cursor.getUTCDay(); + const { minute, hour, dayOfMonth, month, dayOfWeek } = readDateParts(cursor); if ( cron.minute.matches(minute) && diff --git a/public-docs/schedules.md b/public-docs/schedules.md index 042f310b5..68d85a115 100644 --- a/public-docs/schedules.md +++ b/public-docs/schedules.md @@ -59,6 +59,7 @@ Daily GitHub triage on GLM through OpenCode: ```bash paseo schedule create \ --cron "0 14 * * 1-5" \ + --timezone UTC \ --run-now \ --name github-triage \ --provider opencode/openrouter/glm-5.1 \ @@ -66,6 +67,18 @@ paseo schedule create \ "Triage GitHub issues, PRs, and failing checks. Summarize what needs attention." ``` +Morning triage at 9 AM in New York, including daylight saving time changes: + +```bash +paseo schedule create \ + --cron "0 9 * * 1-5" \ + --timezone America/New_York \ + --name morning-triage \ + --provider codex/gpt-5.5 \ + --cwd ~/dev/my-app \ + "Review overnight CI failures and summarize anything urgent." +``` + Heartbeat the current agent: ```bash @@ -89,7 +102,9 @@ paseo schedule update --every 10m --max-runs 6 paseo schedule delete ``` -Use `--every ` for intervals and `--cron ""` for 5-field UTC cron. Interval schedules run once immediately by default; pass `--no-run-now` to wait for the first interval. Cron schedules wait for the next matching time; pass `--run-now` to fire once immediately. +Use `--every ` for intervals and `--cron ""` for 5-field cron. Cron schedules default to UTC. Pass `--timezone ` to interpret cron fields in a local wall-clock time zone, for example `--timezone America/New_York`. The persisted `nextRunAt` is still a UTC instant, but it is computed from that local time zone so recurring jobs stay at the same local time across daylight saving time changes. + +Interval schedules run once immediately by default; pass `--no-run-now` to wait for the first interval. Cron schedules wait for the next matching time; pass `--run-now` to fire once immediately. When targeting a remote daemon with `--host`, pass `--cwd`; your local working directory may not exist on the remote machine.