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-04 16:37:33 +08:00
parent f11aea3223
commit 2b6d7d4ec2
13 changed files with 387 additions and 16 deletions

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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -388,6 +388,10 @@ type ScheduleDeletePayload = Extract<
SessionOutboundMessage,
{ type: "schedule/delete/response" }
>["payload"];
type ScheduleRunOncePayload = Extract<
SessionOutboundMessage,
{ type: "schedule/run-once/response" }
>["payload"];
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
@@ -541,6 +545,7 @@ export interface CreateScheduleOptions {
};
maxRuns?: number;
expiresAt?: string;
runOnCreate?: boolean;
requestId?: string;
}
export interface InspectScheduleOptions {
@@ -3667,6 +3672,7 @@ export class DaemonClient {
...(options.name ? { name: options.name } : {}),
...(typeof options.maxRuns === "number" ? { maxRuns: options.maxRuns } : {}),
...(options.expiresAt ? { expiresAt: options.expiresAt } : {}),
...(typeof options.runOnCreate === "boolean" ? { runOnCreate: options.runOnCreate } : {}),
},
responseType: "schedule/create/response",
timeout: 10000,
@@ -3744,6 +3750,18 @@ export class DaemonClient {
});
}
async scheduleRunOnce(options: InspectScheduleOptions): Promise<ScheduleRunOncePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "schedule/run-once",
scheduleId: options.id,
},
responseType: "schedule/run-once/response",
timeout: 10000,
});
}
async loopRun(options: RunLoopOptions): Promise<LoopRunPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,

View File

@@ -31,6 +31,7 @@ export const ScheduleCreateRequestSchema = z.object({
target: ScheduleCreateTargetSchema,
maxRuns: z.number().int().positive().optional(),
expiresAt: z.string().optional(),
runOnCreate: z.boolean().optional(),
});
export const ScheduleListRequestSchema = z.object({
@@ -68,6 +69,12 @@ export const ScheduleDeleteRequestSchema = z.object({
scheduleId: z.string(),
});
export const ScheduleRunOnceRequestSchema = z.object({
type: z.literal("schedule/run-once"),
requestId: z.string(),
scheduleId: z.string(),
});
export const ScheduleCreateResponseSchema = z.object({
type: z.literal("schedule/create/response"),
payload: z.object({
@@ -130,3 +137,12 @@ export const ScheduleDeleteResponseSchema = z.object({
error: z.string().nullable(),
}),
});
export const ScheduleRunOnceResponseSchema = z.object({
type: z.literal("schedule/run-once/response"),
payload: z.object({
requestId: z.string(),
schedule: StoredScheduleSchema.nullable(),
error: z.string().nullable(),
}),
});

View File

@@ -231,6 +231,7 @@ describe("ScheduleService", () => {
type: "new-agent",
config: { provider: "claude", cwd: tempDir },
},
runOnCreate: false,
});
expect(created.nextRunAt).toBe("2026-01-01T00:01:00.000Z");
@@ -366,4 +367,154 @@ describe("ScheduleService", () => {
}),
).rejects.toThrow("Agent archived-agent is archived");
});
test("defaults --every schedules to fire immediately on creation", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const created = await service.create({
prompt: "every default",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: { provider: "claude", cwd: tempDir },
},
});
expect(created.nextRunAt).toBe(now.toISOString());
});
test("--every with runOnCreate=false waits the full interval", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const created = await service.create({
prompt: "wait interval",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: { provider: "claude", cwd: tempDir },
},
runOnCreate: false,
});
expect(created.nextRunAt).toBe("2026-01-01T00:01:00.000Z");
});
test("--cron defaults to the next cron slot", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const created = await service.create({
prompt: "cron default",
cadence: { type: "cron", expression: "30 9 * * *" },
target: {
type: "new-agent",
config: { provider: "claude", cwd: tempDir },
},
});
expect(created.nextRunAt).toBe("2026-01-01T09:30:00.000Z");
});
test("--cron with runOnCreate=true fires immediately on creation", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const created = await service.create({
prompt: "cron run-now",
cadence: { type: "cron", expression: "30 9 * * *" },
target: {
type: "new-agent",
config: { provider: "claude", cwd: tempDir },
},
runOnCreate: true,
});
expect(created.nextRunAt).toBe(now.toISOString());
});
test("runOnce records a run without changing nextRunAt or completing the schedule", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
now: () => now,
runner: async (schedule) => ({
agentId: "00000000-0000-0000-0000-000000000099",
output: `manual:${schedule.prompt}`,
}),
});
const created = await service.create({
prompt: "manual fire",
cadence: { type: "cron", expression: "30 9 * * *" },
target: {
type: "new-agent",
config: { provider: "claude", cwd: tempDir },
},
maxRuns: 1,
});
expect(created.nextRunAt).toBe("2026-01-01T09:30:00.000Z");
const after = await service.runOnce(created.id);
expect(after.nextRunAt).toBe("2026-01-01T09:30:00.000Z");
expect(after.status).toBe("active");
expect(after.runs).toHaveLength(1);
expect(after.runs[0]).toMatchObject({
status: "succeeded",
agentId: "00000000-0000-0000-0000-000000000099",
output: "manual:manual fire",
});
});
test("runOnce rejects completed schedules", async () => {
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const created = await service.create({
prompt: "one-shot",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: { provider: "claude", cwd: tempDir },
},
maxRuns: 1,
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
await expect(service.runOnce(created.id)).rejects.toThrow("already completed");
});
});

View File

@@ -142,6 +142,8 @@ export class ScheduleService {
const now = this.now();
const prompt = normalizePrompt(input.prompt);
validateScheduleCadence(input.cadence);
const runOnCreate = input.runOnCreate ?? input.cadence.type === "every";
const nextRunAt = runOnCreate ? now : computeNextRunAt(input.cadence, now);
const schedule = await this.store.create({
name: trimOptionalName(input.name),
prompt,
@@ -150,7 +152,7 @@ export class ScheduleService {
status: "active",
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
nextRunAt: computeNextRunAt(input.cadence, now).toISOString(),
nextRunAt: nextRunAt.toISOString(),
lastRunAt: null,
pausedAt: null,
expiresAt: input.expiresAt ?? null,
@@ -221,6 +223,18 @@ export class ScheduleService {
await this.store.delete(id);
}
async runOnce(id: string): Promise<StoredSchedule> {
const schedule = await this.inspect(id);
if (schedule.status === "completed") {
throw new Error(`Schedule ${id} is already completed`);
}
if (this.runningScheduleIds.has(id)) {
throw new Error(`Schedule ${id} is already running`);
}
await this.runSchedule(schedule, this.now(), { manual: true });
return this.inspect(id);
}
async tick(): Promise<void> {
const now = this.now();
const schedules = await this.store.list();
@@ -286,12 +300,17 @@ export class ScheduleService {
);
}
private async runSchedule(schedule: StoredSchedule, now: Date): Promise<void> {
private async runSchedule(
schedule: StoredSchedule,
now: Date,
options?: { manual?: boolean },
): Promise<void> {
const manual = options?.manual === true;
this.runningScheduleIds.add(schedule.id);
const runId = randomUUID();
const runningRun: ScheduleRun = {
id: runId,
scheduledFor: schedule.nextRunAt ?? now.toISOString(),
scheduledFor: manual ? now.toISOString() : (schedule.nextRunAt ?? now.toISOString()),
startedAt: now.toISOString(),
endedAt: null,
status: "running",
@@ -315,6 +334,7 @@ export class ScheduleService {
agentId: result.agentId,
output: result.output,
error: null,
manual,
});
} catch (error) {
await this.finishRun({
@@ -324,6 +344,7 @@ export class ScheduleService {
agentId: null,
output: null,
error: error instanceof Error ? error.message : String(error),
manual,
});
} finally {
this.runningScheduleIds.delete(schedule.id);
@@ -337,6 +358,7 @@ export class ScheduleService {
agentId: string | null;
output: string | null;
error: string | null;
manual: boolean;
}): Promise<void> {
const schedule = await this.inspect(params.scheduleId);
const now = this.now();
@@ -359,7 +381,9 @@ export class ScheduleService {
updatedAt: now.toISOString(),
};
if (shouldCompleteSchedule(updated, now)) {
if (params.manual) {
// Manual one-shot runs do not advance the cadence or recompute completion.
} else if (shouldCompleteSchedule(updated, now)) {
updated = completeSchedule(updated, now);
} else if (updated.status === "paused") {
updated = {

View File

@@ -90,6 +90,7 @@ export interface CreateScheduleInput {
target: ScheduleTarget;
maxRuns?: number | null;
expiresAt?: string | null;
runOnCreate?: boolean | null;
}
export interface ScheduleExecutionResult {

View File

@@ -2130,6 +2130,23 @@ export class Session {
return this.handleChatReadRequest(msg);
case "chat/wait":
return this.handleChatWaitRequest(msg);
case "loop/run":
return this.handleLoopRunRequest(msg);
case "loop/list":
return this.handleLoopListRequest(msg);
case "loop/inspect":
return this.handleLoopInspectRequest(msg);
case "loop/logs":
return this.handleLoopLogsRequest(msg);
case "loop/stop":
return this.handleLoopStopRequest(msg);
default:
return this.dispatchScheduleMessage(msg);
}
}
private dispatchScheduleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "schedule/create":
return this.handleScheduleCreateRequest(msg);
case "schedule/list":
@@ -2144,16 +2161,8 @@ export class Session {
return this.handleScheduleResumeRequest(msg);
case "schedule/delete":
return this.handleScheduleDeleteRequest(msg);
case "loop/run":
return this.handleLoopRunRequest(msg);
case "loop/list":
return this.handleLoopListRequest(msg);
case "loop/inspect":
return this.handleLoopInspectRequest(msg);
case "loop/logs":
return this.handleLoopLogsRequest(msg);
case "loop/stop":
return this.handleLoopStopRequest(msg);
case "schedule/run-once":
return this.handleScheduleRunOnceRequest(msg);
default:
return undefined;
}
@@ -8395,7 +8404,8 @@ export class Session {
| "schedule/logs"
| "schedule/pause"
| "schedule/resume"
| "schedule/delete";
| "schedule/delete"
| "schedule/run-once";
}
>,
error: unknown,
@@ -8428,6 +8438,7 @@ export class Session {
target,
maxRuns: request.maxRuns,
expiresAt: request.expiresAt,
runOnCreate: request.runOnCreate,
});
this.emit({
type: "schedule/create/response",
@@ -8550,6 +8561,24 @@ export class Session {
}
}
private async handleScheduleRunOnceRequest(
request: Extract<SessionInboundMessage, { type: "schedule/run-once" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.runOnce(request.scheduleId);
this.emit({
type: "schedule/run-once/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private emitLoopRpcError(
request: Extract<
SessionInboundMessage,

View File

@@ -28,6 +28,7 @@ import {
SchedulePauseRequestSchema,
ScheduleResumeRequestSchema,
ScheduleDeleteRequestSchema,
ScheduleRunOnceRequestSchema,
ScheduleCreateResponseSchema,
ScheduleListResponseSchema,
ScheduleInspectResponseSchema,
@@ -35,6 +36,7 @@ import {
SchedulePauseResponseSchema,
ScheduleResumeResponseSchema,
ScheduleDeleteResponseSchema,
ScheduleRunOnceResponseSchema,
} from "../server/schedule/rpc-schemas.js";
import {
LoopRunRequestSchema,
@@ -1765,6 +1767,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SchedulePauseRequestSchema,
ScheduleResumeRequestSchema,
ScheduleDeleteRequestSchema,
ScheduleRunOnceRequestSchema,
LoopRunRequestSchema,
LoopListRequestSchema,
LoopInspectRequestSchema,
@@ -3378,6 +3381,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SchedulePauseResponseSchema,
ScheduleResumeResponseSchema,
ScheduleDeleteResponseSchema,
ScheduleRunOnceResponseSchema,
LoopRunResponseSchema,
LoopListResponseSchema,
LoopInspectResponseSchema,
@@ -3483,6 +3487,7 @@ export type ScheduleLogsResponse = z.infer<typeof ScheduleLogsResponseSchema>;
export type SchedulePauseResponse = z.infer<typeof SchedulePauseResponseSchema>;
export type ScheduleResumeResponse = z.infer<typeof ScheduleResumeResponseSchema>;
export type ScheduleDeleteResponse = z.infer<typeof ScheduleDeleteResponseSchema>;
export type ScheduleRunOnceResponse = z.infer<typeof ScheduleRunOnceResponseSchema>;
export type LoopRunResponse = z.infer<typeof LoopRunResponseSchema>;
export type LoopListResponse = z.infer<typeof LoopListResponseSchema>;
export type LoopInspectResponse = z.infer<typeof LoopInspectResponseSchema>;
@@ -3541,6 +3546,7 @@ export type ScheduleLogsRequest = z.infer<typeof ScheduleLogsRequestSchema>;
export type SchedulePauseRequest = z.infer<typeof SchedulePauseRequestSchema>;
export type ScheduleResumeRequest = z.infer<typeof ScheduleResumeRequestSchema>;
export type ScheduleDeleteRequest = z.infer<typeof ScheduleDeleteRequestSchema>;
export type ScheduleRunOnceRequest = z.infer<typeof ScheduleRunOnceRequestSchema>;
export type LoopRunRequest = z.infer<typeof LoopRunRequestSchema>;
export type LoopListRequest = z.infer<typeof LoopListRequestSchema>;
export type LoopInspectRequest = z.infer<typeof LoopInspectRequestSchema>;