schedule: fire --every now by default, add run-once for cron-style triggers (#689)

Interval schedules used to wait the full interval before their first run, which
made `--every 1d` feel broken for fresh schedules. Now `--every` fires
immediately on creation; `--cron` keeps waiting for the next slot. `--no-run-now`
and `--run-now` override the defaults, and the parser rejects redundant combos.

Adds `paseo schedule run-once <id>` to manually trigger a single run without
advancing cadence or recomputing completion. The new `runOnCreate` field and
`schedule/run-once` RPC are additive on the WebSocket schema.
This commit is contained in:
Mohamed Boudra
2026-05-05 19:56:51 +07:00
parent f11aea3223
commit 2b6d7d4ec2
13 changed files with 387 additions and 16 deletions
+5 -1
View File
@@ -20,13 +20,16 @@ export interface ScheduleCreateOptions extends ScheduleCommandOptions {
cwd?: string;
maxRuns?: string;
expiresIn?: string;
runNow?: boolean;
}
export async function runCreateCommand(
prompt: string,
options: ScheduleCreateOptions,
_command: Command,
command: Command,
): Promise<SingleResult<ScheduleRow>> {
const runNowSource = command.getOptionValueSource("runNow");
const runNow = runNowSource === "cli" ? Boolean(options.runNow) : undefined;
const input = parseScheduleCreateInput({
prompt,
every: options.every,
@@ -39,6 +42,7 @@ export async function runCreateCommand(
host: options.host,
maxRuns: options.maxRuns,
expiresIn: options.expiresIn,
runNow,
});
const { client } = await connectScheduleClient(options.host);
try {
@@ -8,6 +8,7 @@ import { runLogsCommand } from "./logs.js";
import { runPauseCommand } from "./pause.js";
import { runResumeCommand } from "./resume.js";
import { runDeleteCommand } from "./delete.js";
import { runRunOnceCommand } from "./run-once.js";
export function createScheduleCommand(): Command {
const schedule = new Command("schedule").description("Manage recurring schedules");
@@ -30,6 +31,8 @@ export function createScheduleCommand(): Command {
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
)
.option("--cwd <path>", "Working directory (default: current; required with --host)")
.option("--run-now", "Fire one immediate run on creation (only with --cron)")
.option("--no-run-now", "Wait the full interval before the first run (only with --every)")
.option("--max-runs <n>", "Maximum number of runs")
.option("--expires-in <duration>", "Time to live for the schedule"),
).action(withOutput(runCreateCommand));
@@ -64,5 +67,12 @@ export function createScheduleCommand(): Command {
schedule.command("delete").description("Delete a schedule").argument("<id>", "Schedule ID"),
).action(withOutput(runDeleteCommand));
addJsonAndDaemonHostOptions(
schedule
.command("run-once")
.description("Manually trigger a single run of a schedule without affecting cadence")
.argument("<id>", "Schedule ID"),
).action(withOutput(runRunOnceCommand));
return schedule;
}
@@ -0,0 +1,33 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
type ScheduleRow,
} from "./shared.js";
export async function runRunOnceCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleRunOnce({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Failed to run schedule once: ${id}`);
}
return {
type: "single",
data: toScheduleRow(payload.schedule),
schema: scheduleSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_RUN_ONCE_FAILED", "run schedule once", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -8,6 +8,12 @@ const baseOptions = {
provider: "claude",
};
const baseCron = {
prompt: "do the thing",
cron: "0 9 * * *",
provider: "claude",
};
describe("parseScheduleCreateInput cwd/host validation", () => {
beforeEach(() => {
vi.spyOn(process, "cwd").mockReturnValue("/local/project");
@@ -60,3 +66,43 @@ describe("parseScheduleCreateInput cwd/host validation", () => {
).toThrow(expect.objectContaining({ code: "MISSING_CWD" }));
});
});
describe("parseScheduleCreateInput first-run timing", () => {
test("--every with no run-now flag fires immediately on creation", () => {
const input = parseScheduleCreateInput(baseOptions);
expect(input.runOnCreate).toBe(true);
});
test("--every with --no-run-now waits the interval", () => {
const input = parseScheduleCreateInput({ ...baseOptions, runNow: false });
expect(input.runOnCreate).toBe(false);
});
test("--cron with no run-now flag waits for the next cron slot", () => {
const input = parseScheduleCreateInput(baseCron);
expect(input.runOnCreate).toBe(false);
});
test("--cron with --run-now fires immediately on creation", () => {
const input = parseScheduleCreateInput({ ...baseCron, runNow: true });
expect(input.runOnCreate).toBe(true);
});
test("--every with --run-now is rejected as redundant", () => {
expect(() => parseScheduleCreateInput({ ...baseOptions, runNow: true })).toThrow(
expect.objectContaining({
code: "REDUNDANT_RUN_NOW",
message: expect.stringContaining("--run-now is redundant with --every"),
}),
);
});
test("--cron with --no-run-now is rejected as redundant", () => {
expect(() => parseScheduleCreateInput({ ...baseCron, runNow: false })).toThrow(
expect.objectContaining({
code: "REDUNDANT_NO_RUN_NOW",
message: expect.stringContaining("--no-run-now is redundant with --cron"),
}),
);
});
});
@@ -135,6 +135,7 @@ export function parseScheduleCreateInput(options: {
host?: string;
maxRuns?: string;
expiresIn?: string;
runNow?: boolean;
}): CreateScheduleInput {
const prompt = options.prompt.trim();
if (!prompt) {
@@ -165,6 +166,8 @@ export function parseScheduleCreateInput(options: {
} satisfies CommandError;
}
const runOnCreate = resolveRunOnCreate(options.runNow, cadence.type);
const targetValue = options.target?.trim();
const modeId = options.mode?.trim();
const hasExplicitNewAgentOption = options.provider !== undefined || options.mode !== undefined;
@@ -199,12 +202,34 @@ export function parseScheduleCreateInput(options: {
prompt,
cadence,
target,
runOnCreate,
...(options.name?.trim() ? { name: options.name.trim() } : {}),
...(maxRuns !== undefined ? { maxRuns } : {}),
...(expiresAt ? { expiresAt } : {}),
};
}
function resolveRunOnCreate(
runNow: boolean | undefined,
cadenceType: ScheduleCadence["type"],
): boolean {
if (runNow === true && cadenceType === "every") {
throw {
code: "REDUNDANT_RUN_NOW",
message: "--run-now is redundant with --every (interval schedules already fire on creation)",
details: "Drop --run-now, or use --no-run-now to wait the full interval before the first run",
} satisfies CommandError;
}
if (runNow === false && cadenceType === "cron") {
throw {
code: "REDUNDANT_NO_RUN_NOW",
message: "--no-run-now is redundant with --cron (cron schedules never fire on creation)",
details: "Drop --no-run-now, or use --run-now to fire one immediate run on creation",
} satisfies CommandError;
}
return runNow ?? cadenceType === "every";
}
function parsePositiveInt(value: string, flag: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
@@ -85,6 +85,7 @@ export interface CreateScheduleInput {
target: ScheduleTarget;
maxRuns?: number;
expiresAt?: string;
runOnCreate?: boolean;
}
export interface ScheduleCreatePayload {
@@ -129,6 +130,12 @@ export interface ScheduleDeletePayload {
error: string | null;
}
export interface ScheduleRunOncePayload {
requestId: string;
schedule: ScheduleRecord | null;
error: string | null;
}
export interface ScheduleDaemonClient {
scheduleCreate(input: CreateScheduleInput): Promise<ScheduleCreatePayload>;
scheduleList(): Promise<ScheduleListPayload>;
@@ -137,5 +144,6 @@ export interface ScheduleDaemonClient {
schedulePause(input: { id: string }): Promise<SchedulePausePayload>;
scheduleResume(input: { id: string }): Promise<ScheduleResumePayload>;
scheduleDelete(input: { id: string }): Promise<ScheduleDeletePayload>;
scheduleRunOnce(input: { id: string }): Promise<ScheduleRunOncePayload>;
close(): Promise<void>;
}