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-05-31 18:32:45 +02:00
committed by GitHub
parent 922a93af2f
commit 632c48fde3
17 changed files with 431 additions and 19 deletions

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`)
- `{ 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`)

View File

@@ -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";

View File

@@ -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,

View File

@@ -21,6 +21,7 @@ export function createScheduleCommand(): Command {
.argument("<prompt>", "Prompt to run on the schedule")
.option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h)")
.option("--cron <expr>", "Cron cadence expression")
.option("--timezone <iana>", "IANA time zone for cron cadence (default: UTC)")
.option("--name <name>", "Optional schedule name")
.option("--target <self|new-agent|agent-id>", "Run target")
.option(
@@ -82,6 +83,7 @@ export function createScheduleCommand(): Command {
.argument("<id>", "Schedule ID")
.option("--every <duration>", "Switch to fixed interval cadence (for example: 5m, 1h)")
.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("--prompt <text>", "Replace the schedule prompt")
.option(

View File

@@ -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<ScheduleRow> = {
@@ -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) },

View File

@@ -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({

View File

@@ -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 {

View File

@@ -8,6 +8,7 @@ export type ScheduleCadence =
| {
type: "cron";
expression: string;
timezone?: string;
};
export type ScheduleTarget =

View File

@@ -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,

View File

@@ -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;

View File

@@ -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",
});
});
});

View File

@@ -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<typeof ScheduleCadenceSchema>;

View File

@@ -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();

View File

@@ -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

View File

@@ -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");
});
});

View File

@@ -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<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 {
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) &&

View File

@@ -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 <id> --every 10m --max-runs 6
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.