Add timezone-aware cron schedules (#1232)

* feat: add timezone-aware cron schedules

* chore: normalize schedule timezone property

* fix: reject blank schedule timezone in mcp

* Stabilize terminal resize browser repro

* Fix terminal resize repro row assertions

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Daniel Salmerón Amselem
2026-06-01 00:32:45 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 922a93af2f
commit 632c48fde3
17 changed files with 431 additions and 19 deletions
+1 -1
View File
@@ -206,7 +206,7 @@ One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
### Nested: ScheduleCadence (discriminated union on `type`) ### Nested: ScheduleCadence (discriminated union on `type`)
- `{ type: "every", everyMs: number }` — interval in milliseconds - `{ 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`) ### Nested: ScheduleTarget (discriminated union on `type`)
@@ -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 () => { it("snapshot-restored rows stay frozen at the snapshot width after the terminal grows", async () => {
await page.viewport(1600, 700); await page.viewport(1600, 700);
const m = mount(560, 360); // ~70 cols const m = mount(560, 360); // ~70 cols
await waitFor(() => m.terminal.cols > 0); await waitFor(() => m.terminal.cols > 40);
const narrowCols = m.terminal.cols; const narrowCols = m.terminal.cols;
// 1) Mid-stream snapshot arrives (server overflowed 256KB). It carries the // 1) Mid-stream snapshot arrives (server overflowed 256KB). It carries the
// long line wrapped at the server width as separate grid rows. // long line wrapped at the server width as separate grid rows.
await renderSnapshotCommitted(m.runtime, buildWrappedSnapshot(narrowCols)); 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). // 2) Normal post-snapshot streaming resumes (autowrap on -> soft-wrapped).
for (let seq = 90; seq <= 96; seq++) { for (let seq = 90; seq <= 96; seq++) {
await writeCommitted(m.runtime, encodeTerminalOutput(pinoLine(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. // 3) User resizes the terminal wider.
m.root.style.width = "1480px"; m.root.style.width = "1480px";
@@ -13,6 +13,7 @@ import {
export interface ScheduleCreateOptions extends ScheduleCommandOptions { export interface ScheduleCreateOptions extends ScheduleCommandOptions {
every?: string; every?: string;
cron?: string; cron?: string;
timezone?: string;
name?: string; name?: string;
target?: string; target?: string;
provider?: string; provider?: string;
@@ -34,6 +35,7 @@ export async function runCreateCommand(
prompt, prompt,
every: options.every, every: options.every,
cron: options.cron, cron: options.cron,
timezone: options.timezone,
name: options.name, name: options.name,
target: options.target, target: options.target,
provider: options.provider, provider: options.provider,
@@ -21,6 +21,7 @@ export function createScheduleCommand(): Command {
.argument("<prompt>", "Prompt to run on the schedule") .argument("<prompt>", "Prompt to run on the schedule")
.option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h)") .option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h)")
.option("--cron <expr>", "Cron cadence expression") .option("--cron <expr>", "Cron cadence expression")
.option("--timezone <iana>", "IANA time zone for cron cadence (default: UTC)")
.option("--name <name>", "Optional schedule name") .option("--name <name>", "Optional schedule name")
.option("--target <self|new-agent|agent-id>", "Run target") .option("--target <self|new-agent|agent-id>", "Run target")
.option( .option(
@@ -82,6 +83,7 @@ export function createScheduleCommand(): Command {
.argument("<id>", "Schedule ID") .argument("<id>", "Schedule ID")
.option("--every <duration>", "Switch to fixed interval cadence (for example: 5m, 1h)") .option("--every <duration>", "Switch to fixed interval cadence (for example: 5m, 1h)")
.option("--cron <expr>", "Switch to cron cadence expression") .option("--cron <expr>", "Switch to cron cadence expression")
.option("--timezone <iana>", "IANA time zone for cron cadence (requires --cron)")
.option("--name <name>", "Rename the schedule (empty string clears the name)") .option("--name <name>", "Rename the schedule (empty string clears the name)")
.option("--prompt <text>", "Replace the schedule prompt") .option("--prompt <text>", "Replace the schedule prompt")
.option( .option(
+2 -2
View File
@@ -1,5 +1,5 @@
import type { OutputSchema } from "../../output/index.js"; 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"; import type { ScheduleRecord, ScheduleRunRecord } from "./types.js";
export const scheduleSchema: OutputSchema<ScheduleRow> = { export const scheduleSchema: OutputSchema<ScheduleRow> = {
@@ -73,7 +73,7 @@ export function createScheduleInspectRows(schedule: ScheduleRecord): ScheduleIns
key: "Cadence", key: "Cadence",
value: value:
schedule.cadence.type === "cron" schedule.cadence.type === "cron"
? `cron:${schedule.cadence.expression}` ? formatCadence(schedule.cadence)
: `every:${schedule.cadence.everyMs}ms`, : `every:${schedule.cadence.everyMs}ms`,
}, },
{ key: "Target", value: formatTarget(schedule.target) }, { key: "Target", value: formatTarget(schedule.target) },
@@ -88,6 +88,19 @@ describe("parseScheduleCreateInput first-run timing", () => {
expect(input.runOnCreate).toBe(true); 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", () => { test("--every with --run-now is rejected as redundant", () => {
expect(() => parseScheduleCreateInput({ ...baseOptions, runNow: true })).toThrow( expect(() => parseScheduleCreateInput({ ...baseOptions, runNow: true })).toThrow(
expect.objectContaining({ 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", () => { 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", () => { test("rejects passing both --every and --cron", () => {
expect(() => parseScheduleUpdateInput({ id: "abc", every: "5m", cron: "0 9 * * *" })).toThrow( expect(() => parseScheduleUpdateInput({ id: "abc", every: "5m", cron: "0 9 * * *" })).toThrow(
expect.objectContaining({ code: "INVALID_CADENCE" }), 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", () => { test("parses provider/model shorthand and explicit mode", () => {
expect( expect(
parseScheduleUpdateInput({ parseScheduleUpdateInput({
+33 -4
View File
@@ -49,7 +49,8 @@ export function toScheduleCommandError(code: string, action: string, error: unkn
export function formatCadence(cadence: ScheduleCadence): string { export function formatCadence(cadence: ScheduleCadence): string {
if (cadence.type === "cron") { if (cadence.type === "cron") {
return `cron:${cadence.expression}`; const timezoneSuffix = cadence.timezone ? ` (${cadence.timezone})` : "";
return `cron:${cadence.expression}${timezoneSuffix}`;
} }
return `every:${formatDurationMs(cadence.everyMs)}`; return `every:${formatDurationMs(cadence.everyMs)}`;
} }
@@ -129,6 +130,7 @@ export function parseScheduleCreateInput(options: {
prompt: string; prompt: string;
every?: string; every?: string;
cron?: string; cron?: string;
timezone?: string;
name?: string; name?: string;
target?: string; target?: string;
provider?: string; provider?: string;
@@ -147,7 +149,7 @@ export function parseScheduleCreateInput(options: {
} satisfies CommandError; } satisfies CommandError;
} }
const cadence = parseCadenceFromFlags(options.every, options.cron); const cadence = parseCadenceFromFlags(options.every, options.cron, options.timezone);
if (!cadence) { if (!cadence) {
throw { throw {
code: "INVALID_CADENCE", code: "INVALID_CADENCE",
@@ -232,6 +234,7 @@ export interface ScheduleUpdateOptionsInput {
id: string; id: string;
every?: string; every?: string;
cron?: string; cron?: string;
timezone?: string;
name?: string; name?: string;
prompt?: string; prompt?: string;
provider?: string; provider?: string;
@@ -253,7 +256,7 @@ export function parseScheduleUpdateInput(options: ScheduleUpdateOptionsInput): U
} satisfies CommandError; } satisfies CommandError;
} }
const cadence = parseCadenceFromFlags(options.every, options.cron); const cadence = parseCadenceFromFlags(options.every, options.cron, options.timezone);
const newAgentConfig = buildNewAgentConfigPatch(options); const newAgentConfig = buildNewAgentConfigPatch(options);
const maxRuns = parseUpdateMaxRuns(options); const maxRuns = parseUpdateMaxRuns(options);
const expiresAt = parseUpdateExpiresAt(options); const expiresAt = parseUpdateExpiresAt(options);
@@ -288,6 +291,7 @@ export function parseScheduleUpdateInput(options: ScheduleUpdateOptionsInput): U
function parseCadenceFromFlags( function parseCadenceFromFlags(
every: string | undefined, every: string | undefined,
cron: string | undefined, cron: string | undefined,
timezone: string | undefined,
): ScheduleCadence | undefined { ): ScheduleCadence | undefined {
if (every !== undefined && cron !== undefined) { if (every !== undefined && cron !== undefined) {
throw { throw {
@@ -295,15 +299,40 @@ function parseCadenceFromFlags(
message: "Specify at most one of --every or --cron", message: "Specify at most one of --every or --cron",
} satisfies CommandError; } 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) { if (every !== undefined) {
return { type: "every", everyMs: parseDuration(every) }; return { type: "every", everyMs: parseDuration(every) };
} }
if (cron !== undefined) { if (cron !== undefined) {
return { type: "cron", expression: cron.trim() }; return {
type: "cron",
expression: cron.trim(),
...(trimmedTimeZone ? { timezone: trimmedTimeZone } : {}),
};
} }
return undefined; 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 { function parseUpdateMaxRuns(options: ScheduleUpdateOptionsInput): number | null | undefined {
if (options.maxRuns !== undefined && options.clearMaxRuns) { if (options.maxRuns !== undefined && options.clearMaxRuns) {
throw { throw {
@@ -8,6 +8,7 @@ export type ScheduleCadence =
| { | {
type: "cron"; type: "cron";
expression: string; expression: string;
timezone?: string;
}; };
export type ScheduleTarget = export type ScheduleTarget =
@@ -15,6 +15,7 @@ import {
export interface ScheduleUpdateOptions extends ScheduleCommandOptions { export interface ScheduleUpdateOptions extends ScheduleCommandOptions {
every?: string; every?: string;
cron?: string; cron?: string;
timezone?: string;
name?: string; name?: string;
prompt?: string; prompt?: string;
provider?: string; provider?: string;
@@ -36,6 +37,7 @@ export async function runUpdateCommand(
id, id,
every: options.every, every: options.every,
cron: options.cron, cron: options.cron,
timezone: options.timezone,
name: options.name, name: options.name,
prompt: options.prompt, prompt: options.prompt,
provider: options.provider, provider: options.provider,
+2
View File
@@ -568,6 +568,7 @@ export interface CreateScheduleOptions {
| { | {
type: "cron"; type: "cron";
expression: string; expression: string;
timezone?: string;
}; };
target: target:
| { | {
@@ -623,6 +624,7 @@ export interface UpdateScheduleOptions {
| { | {
type: "cron"; type: "cron";
expression: string; expression: string;
timezone?: string;
}; };
newAgentConfig?: UpdateScheduleNewAgentConfig; newAgentConfig?: UpdateScheduleNewAgentConfig;
maxRuns?: number | null; maxRuns?: number | null;
@@ -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",
});
});
});
+1
View File
@@ -12,6 +12,7 @@ export const ScheduleCadenceSchema = z.discriminatedUnion("type", [
z.object({ z.object({
type: z.literal("cron"), type: z.literal("cron"),
expression: z.string().trim().min(1), expression: z.string().trim().min(1),
timezone: z.string().trim().min(1).optional(),
}), }),
]); ]);
export type ScheduleCadence = z.infer<typeof ScheduleCadenceSchema>; export type ScheduleCadence = z.infer<typeof ScheduleCadenceSchema>;
@@ -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 () => { it("still rejects both real every and cron inputs", async () => {
const { agentManager, agentStorage } = createTestDeps(); const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(); const create = vi.fn();
@@ -2352,6 +2384,54 @@ describe("create_schedule MCP tool", () => {
expect(create).not.toHaveBeenCalled(); 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([ it.each([
{ {
label: "missing both cadence fields", 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 () => { it("accepts a blank cron field when updating every cadence", async () => {
const { agentManager, agentStorage } = createTestDeps(); const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule(); const stored = makeStoredSchedule();
@@ -2542,6 +2651,52 @@ describe("update_schedule MCP tool", () => {
expect(update).not.toHaveBeenCalled(); 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 () => { it("passes new-agent config and expiry updates", async () => {
const { agentManager, agentStorage } = createTestDeps(); const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule(); const stored = makeStoredSchedule();
+40 -3
View File
@@ -333,6 +333,7 @@ interface ScheduleUpdateToolInput {
id: string; id: string;
every?: string; every?: string;
cron?: string; cron?: string;
timezone?: string;
name?: string | null; name?: string | null;
prompt?: string; prompt?: string;
maxRuns?: number | null; maxRuns?: number | null;
@@ -357,18 +358,30 @@ function normalizeScheduleCadenceArg(value: string | undefined): string | undefi
return trimmed; return trimmed;
} }
function normalizeScheduleTimeZoneArg(value: string | undefined): string | undefined {
return normalizeScheduleCadenceArg(value);
}
function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleCadence | undefined { function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleCadence | undefined {
const every = normalizeScheduleCadenceArg(input.every); const every = normalizeScheduleCadenceArg(input.every);
const cron = normalizeScheduleCadenceArg(input.cron); const cron = normalizeScheduleCadenceArg(input.cron);
const timeZone = normalizeScheduleTimeZoneArg(input.timezone);
if (every !== undefined && cron !== undefined) { if (every !== undefined && cron !== undefined) {
throw new Error("Specify at most one of every or cron"); 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) { if (every !== undefined) {
return { type: "every", everyMs: parseDurationString(every) }; return { type: "every", everyMs: parseDurationString(every) };
} }
if (cron !== undefined) { if (cron !== undefined) {
return { type: "cron", expression: cron }; return {
type: "cron",
expression: cron,
...(timeZone !== undefined ? { timezone: timeZone } : {}),
};
} }
return undefined; return undefined;
} }
@@ -1583,6 +1596,14 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
prompt: z.string().trim().min(1, "prompt is required"), prompt: z.string().trim().min(1, "prompt is required"),
every: z.string().optional(), every: z.string().optional(),
cron: 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(), name: z.string().optional(),
target: z.enum(["self", "new-agent"]).optional(), target: z.enum(["self", "new-agent"]).optional(),
provider: AgentProviderEnum.optional().describe( provider: AgentProviderEnum.optional().describe(
@@ -1594,18 +1615,22 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}, },
outputSchema: ScheduleSummarySchema.shape, 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) { if (!scheduleService) {
throw new Error("Schedule service is not configured"); throw new Error("Schedule service is not configured");
} }
const normalizedEvery = normalizeScheduleCadenceArg(every); const normalizedEvery = normalizeScheduleCadenceArg(every);
const normalizedCron = normalizeScheduleCadenceArg(cron); const normalizedCron = normalizeScheduleCadenceArg(cron);
const normalizedTimeZone = normalizeScheduleTimeZoneArg(timezone);
const cadenceCount = const cadenceCount =
Number(normalizedEvery !== undefined) + Number(normalizedCron !== undefined); Number(normalizedEvery !== undefined) + Number(normalizedCron !== undefined);
if (cadenceCount !== 1) { if (cadenceCount !== 1) {
throw new Error("Specify exactly one of every or cron"); 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 = const scheduleTarget =
target === "self" target === "self"
@@ -1643,7 +1668,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
cadence: cadence:
normalizedEvery !== undefined normalizedEvery !== undefined
? { type: "every" as const, everyMs: parseDurationString(normalizedEvery) } ? { 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, target: scheduleTarget,
...(name?.trim() ? { name: name.trim() } : {}), ...(name?.trim() ? { name: name.trim() } : {}),
...(maxRuns === undefined ? {} : { maxRuns }), ...(maxRuns === undefined ? {} : { maxRuns }),
@@ -1792,6 +1821,14 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
id: z.string(), id: z.string(),
every: z.string().optional().describe("New interval duration string (e.g. 5m, 1h)."), every: z.string().optional().describe("New interval duration string (e.g. 5m, 1h)."),
cron: z.string().optional().describe("New cron expression."), 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)."), name: z.string().nullable().optional().describe("New name (null to clear)."),
prompt: z.string().trim().min(1).optional().describe("New prompt text."), prompt: z.string().trim().min(1).optional().describe("New prompt text."),
maxRuns: z maxRuns: z
@@ -20,9 +20,47 @@ describe("schedule cron cadence", () => {
expect(next.toISOString()).toBe("2026-01-05T09:15:00.000Z"); 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", () => { test("rejects invalid cron expressions", () => {
expect(() => validateScheduleCadence({ type: "cron", expression: "not-a-valid-cron" })).toThrow( expect(() => validateScheduleCadence({ type: "cron", expression: "not-a-valid-cron" })).toThrow(
"Cron expressions must have 5 fields", "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");
});
}); });
+66 -5
View File
@@ -81,6 +81,14 @@ interface ParsedCronExpression {
dayOfWeek: CronFieldMatcher; dayOfWeek: CronFieldMatcher;
} }
interface CronDateParts {
minute: number;
hour: number;
dayOfMonth: number;
month: number;
dayOfWeek: number;
}
function parseCronExpression(expression: string): ParsedCronExpression { function parseCronExpression(expression: string): ParsedCronExpression {
const parts = expression.trim().split(/\s+/); const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) { 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<string, string> = {};
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 { export function validateScheduleCadence(cadence: ScheduleCadence): void {
if (cadence.type === "cron") { if (cadence.type === "cron") {
parseCronExpression(cadence.expression); 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 cron = parseCronExpression(cadence.expression);
const readDateParts = createCronDatePartsReader(cadence.timezone);
const limit = 366 * 24 * 60; const limit = 366 * 24 * 60;
let cursor = startOfNextMinute(after); let cursor = startOfNextMinute(after);
for (let index = 0; index < limit; index += 1) { for (let index = 0; index < limit; index += 1) {
const minute = cursor.getUTCMinutes(); const { minute, hour, dayOfMonth, month, dayOfWeek } = readDateParts(cursor);
const hour = cursor.getUTCHours();
const dayOfMonth = cursor.getUTCDate();
const month = cursor.getUTCMonth() + 1;
const dayOfWeek = cursor.getUTCDay();
if ( if (
cron.minute.matches(minute) && cron.minute.matches(minute) &&
+16 -1
View File
@@ -59,6 +59,7 @@ Daily GitHub triage on GLM through OpenCode:
```bash ```bash
paseo schedule create \ paseo schedule create \
--cron "0 14 * * 1-5" \ --cron "0 14 * * 1-5" \
--timezone UTC \
--run-now \ --run-now \
--name github-triage \ --name github-triage \
--provider opencode/openrouter/glm-5.1 \ --provider opencode/openrouter/glm-5.1 \
@@ -66,6 +67,18 @@ paseo schedule create \
"Triage GitHub issues, PRs, and failing checks. Summarize what needs attention." "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: Heartbeat the current agent:
```bash ```bash
@@ -89,7 +102,9 @@ paseo schedule update <id> --every 10m --max-runs 6
paseo schedule delete <id> paseo schedule delete <id>
``` ```
Use `--every <duration>` for intervals and `--cron "<expr>"` 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 <duration>` for intervals and `--cron "<expr>"` for 5-field cron. Cron schedules default to UTC. Pass `--timezone <IANA>` 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. When targeting a remote daemon with `--host`, pass `--cwd`; your local working directory may not exist on the remote machine.