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:
@@ -20,13 +20,16 @@ export interface ScheduleCreateOptions extends ScheduleCommandOptions {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
maxRuns?: string;
|
maxRuns?: string;
|
||||||
expiresIn?: string;
|
expiresIn?: string;
|
||||||
|
runNow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runCreateCommand(
|
export async function runCreateCommand(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
options: ScheduleCreateOptions,
|
options: ScheduleCreateOptions,
|
||||||
_command: Command,
|
command: Command,
|
||||||
): Promise<SingleResult<ScheduleRow>> {
|
): Promise<SingleResult<ScheduleRow>> {
|
||||||
|
const runNowSource = command.getOptionValueSource("runNow");
|
||||||
|
const runNow = runNowSource === "cli" ? Boolean(options.runNow) : undefined;
|
||||||
const input = parseScheduleCreateInput({
|
const input = parseScheduleCreateInput({
|
||||||
prompt,
|
prompt,
|
||||||
every: options.every,
|
every: options.every,
|
||||||
@@ -39,6 +42,7 @@ export async function runCreateCommand(
|
|||||||
host: options.host,
|
host: options.host,
|
||||||
maxRuns: options.maxRuns,
|
maxRuns: options.maxRuns,
|
||||||
expiresIn: options.expiresIn,
|
expiresIn: options.expiresIn,
|
||||||
|
runNow,
|
||||||
});
|
});
|
||||||
const { client } = await connectScheduleClient(options.host);
|
const { client } = await connectScheduleClient(options.host);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { runLogsCommand } from "./logs.js";
|
|||||||
import { runPauseCommand } from "./pause.js";
|
import { runPauseCommand } from "./pause.js";
|
||||||
import { runResumeCommand } from "./resume.js";
|
import { runResumeCommand } from "./resume.js";
|
||||||
import { runDeleteCommand } from "./delete.js";
|
import { runDeleteCommand } from "./delete.js";
|
||||||
|
import { runRunOnceCommand } from "./run-once.js";
|
||||||
|
|
||||||
export function createScheduleCommand(): Command {
|
export function createScheduleCommand(): Command {
|
||||||
const schedule = new Command("schedule").description("Manage recurring schedules");
|
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)",
|
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
|
||||||
)
|
)
|
||||||
.option("--cwd <path>", "Working directory (default: current; required with --host)")
|
.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("--max-runs <n>", "Maximum number of runs")
|
||||||
.option("--expires-in <duration>", "Time to live for the schedule"),
|
.option("--expires-in <duration>", "Time to live for the schedule"),
|
||||||
).action(withOutput(runCreateCommand));
|
).action(withOutput(runCreateCommand));
|
||||||
@@ -64,5 +67,12 @@ export function createScheduleCommand(): Command {
|
|||||||
schedule.command("delete").description("Delete a schedule").argument("<id>", "Schedule ID"),
|
schedule.command("delete").description("Delete a schedule").argument("<id>", "Schedule ID"),
|
||||||
).action(withOutput(runDeleteCommand));
|
).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;
|
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",
|
provider: "claude",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const baseCron = {
|
||||||
|
prompt: "do the thing",
|
||||||
|
cron: "0 9 * * *",
|
||||||
|
provider: "claude",
|
||||||
|
};
|
||||||
|
|
||||||
describe("parseScheduleCreateInput cwd/host validation", () => {
|
describe("parseScheduleCreateInput cwd/host validation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.spyOn(process, "cwd").mockReturnValue("/local/project");
|
vi.spyOn(process, "cwd").mockReturnValue("/local/project");
|
||||||
@@ -60,3 +66,43 @@ describe("parseScheduleCreateInput cwd/host validation", () => {
|
|||||||
).toThrow(expect.objectContaining({ code: "MISSING_CWD" }));
|
).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;
|
host?: string;
|
||||||
maxRuns?: string;
|
maxRuns?: string;
|
||||||
expiresIn?: string;
|
expiresIn?: string;
|
||||||
|
runNow?: boolean;
|
||||||
}): CreateScheduleInput {
|
}): CreateScheduleInput {
|
||||||
const prompt = options.prompt.trim();
|
const prompt = options.prompt.trim();
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
@@ -165,6 +166,8 @@ export function parseScheduleCreateInput(options: {
|
|||||||
} satisfies CommandError;
|
} satisfies CommandError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runOnCreate = resolveRunOnCreate(options.runNow, cadence.type);
|
||||||
|
|
||||||
const targetValue = options.target?.trim();
|
const targetValue = options.target?.trim();
|
||||||
const modeId = options.mode?.trim();
|
const modeId = options.mode?.trim();
|
||||||
const hasExplicitNewAgentOption = options.provider !== undefined || options.mode !== undefined;
|
const hasExplicitNewAgentOption = options.provider !== undefined || options.mode !== undefined;
|
||||||
@@ -199,12 +202,34 @@ export function parseScheduleCreateInput(options: {
|
|||||||
prompt,
|
prompt,
|
||||||
cadence,
|
cadence,
|
||||||
target,
|
target,
|
||||||
|
runOnCreate,
|
||||||
...(options.name?.trim() ? { name: options.name.trim() } : {}),
|
...(options.name?.trim() ? { name: options.name.trim() } : {}),
|
||||||
...(maxRuns !== undefined ? { maxRuns } : {}),
|
...(maxRuns !== undefined ? { maxRuns } : {}),
|
||||||
...(expiresAt ? { expiresAt } : {}),
|
...(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 {
|
function parsePositiveInt(value: string, flag: string): number {
|
||||||
const parsed = Number.parseInt(value, 10);
|
const parsed = Number.parseInt(value, 10);
|
||||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export interface CreateScheduleInput {
|
|||||||
target: ScheduleTarget;
|
target: ScheduleTarget;
|
||||||
maxRuns?: number;
|
maxRuns?: number;
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
|
runOnCreate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleCreatePayload {
|
export interface ScheduleCreatePayload {
|
||||||
@@ -129,6 +130,12 @@ export interface ScheduleDeletePayload {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScheduleRunOncePayload {
|
||||||
|
requestId: string;
|
||||||
|
schedule: ScheduleRecord | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScheduleDaemonClient {
|
export interface ScheduleDaemonClient {
|
||||||
scheduleCreate(input: CreateScheduleInput): Promise<ScheduleCreatePayload>;
|
scheduleCreate(input: CreateScheduleInput): Promise<ScheduleCreatePayload>;
|
||||||
scheduleList(): Promise<ScheduleListPayload>;
|
scheduleList(): Promise<ScheduleListPayload>;
|
||||||
@@ -137,5 +144,6 @@ export interface ScheduleDaemonClient {
|
|||||||
schedulePause(input: { id: string }): Promise<SchedulePausePayload>;
|
schedulePause(input: { id: string }): Promise<SchedulePausePayload>;
|
||||||
scheduleResume(input: { id: string }): Promise<ScheduleResumePayload>;
|
scheduleResume(input: { id: string }): Promise<ScheduleResumePayload>;
|
||||||
scheduleDelete(input: { id: string }): Promise<ScheduleDeletePayload>;
|
scheduleDelete(input: { id: string }): Promise<ScheduleDeletePayload>;
|
||||||
|
scheduleRunOnce(input: { id: string }): Promise<ScheduleRunOncePayload>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,6 +388,10 @@ type ScheduleDeletePayload = Extract<
|
|||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "schedule/delete/response" }
|
{ type: "schedule/delete/response" }
|
||||||
>["payload"];
|
>["payload"];
|
||||||
|
type ScheduleRunOncePayload = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "schedule/run-once/response" }
|
||||||
|
>["payload"];
|
||||||
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
|
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
|
||||||
|
|
||||||
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
|
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
|
||||||
@@ -541,6 +545,7 @@ export interface CreateScheduleOptions {
|
|||||||
};
|
};
|
||||||
maxRuns?: number;
|
maxRuns?: number;
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
|
runOnCreate?: boolean;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
}
|
}
|
||||||
export interface InspectScheduleOptions {
|
export interface InspectScheduleOptions {
|
||||||
@@ -3667,6 +3672,7 @@ export class DaemonClient {
|
|||||||
...(options.name ? { name: options.name } : {}),
|
...(options.name ? { name: options.name } : {}),
|
||||||
...(typeof options.maxRuns === "number" ? { maxRuns: options.maxRuns } : {}),
|
...(typeof options.maxRuns === "number" ? { maxRuns: options.maxRuns } : {}),
|
||||||
...(options.expiresAt ? { expiresAt: options.expiresAt } : {}),
|
...(options.expiresAt ? { expiresAt: options.expiresAt } : {}),
|
||||||
|
...(typeof options.runOnCreate === "boolean" ? { runOnCreate: options.runOnCreate } : {}),
|
||||||
},
|
},
|
||||||
responseType: "schedule/create/response",
|
responseType: "schedule/create/response",
|
||||||
timeout: 10000,
|
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> {
|
async loopRun(options: RunLoopOptions): Promise<LoopRunPayload> {
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
requestId: options.requestId,
|
requestId: options.requestId,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const ScheduleCreateRequestSchema = z.object({
|
|||||||
target: ScheduleCreateTargetSchema,
|
target: ScheduleCreateTargetSchema,
|
||||||
maxRuns: z.number().int().positive().optional(),
|
maxRuns: z.number().int().positive().optional(),
|
||||||
expiresAt: z.string().optional(),
|
expiresAt: z.string().optional(),
|
||||||
|
runOnCreate: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ScheduleListRequestSchema = z.object({
|
export const ScheduleListRequestSchema = z.object({
|
||||||
@@ -68,6 +69,12 @@ export const ScheduleDeleteRequestSchema = z.object({
|
|||||||
scheduleId: z.string(),
|
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({
|
export const ScheduleCreateResponseSchema = z.object({
|
||||||
type: z.literal("schedule/create/response"),
|
type: z.literal("schedule/create/response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -130,3 +137,12 @@ export const ScheduleDeleteResponseSchema = z.object({
|
|||||||
error: z.string().nullable(),
|
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(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ describe("ScheduleService", () => {
|
|||||||
type: "new-agent",
|
type: "new-agent",
|
||||||
config: { provider: "claude", cwd: tempDir },
|
config: { provider: "claude", cwd: tempDir },
|
||||||
},
|
},
|
||||||
|
runOnCreate: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(created.nextRunAt).toBe("2026-01-01T00:01:00.000Z");
|
expect(created.nextRunAt).toBe("2026-01-01T00:01:00.000Z");
|
||||||
@@ -366,4 +367,154 @@ describe("ScheduleService", () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrow("Agent archived-agent is archived");
|
).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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export class ScheduleService {
|
|||||||
const now = this.now();
|
const now = this.now();
|
||||||
const prompt = normalizePrompt(input.prompt);
|
const prompt = normalizePrompt(input.prompt);
|
||||||
validateScheduleCadence(input.cadence);
|
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({
|
const schedule = await this.store.create({
|
||||||
name: trimOptionalName(input.name),
|
name: trimOptionalName(input.name),
|
||||||
prompt,
|
prompt,
|
||||||
@@ -150,7 +152,7 @@ export class ScheduleService {
|
|||||||
status: "active",
|
status: "active",
|
||||||
createdAt: now.toISOString(),
|
createdAt: now.toISOString(),
|
||||||
updatedAt: now.toISOString(),
|
updatedAt: now.toISOString(),
|
||||||
nextRunAt: computeNextRunAt(input.cadence, now).toISOString(),
|
nextRunAt: nextRunAt.toISOString(),
|
||||||
lastRunAt: null,
|
lastRunAt: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
expiresAt: input.expiresAt ?? null,
|
expiresAt: input.expiresAt ?? null,
|
||||||
@@ -221,6 +223,18 @@ export class ScheduleService {
|
|||||||
await this.store.delete(id);
|
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> {
|
async tick(): Promise<void> {
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
const schedules = await this.store.list();
|
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);
|
this.runningScheduleIds.add(schedule.id);
|
||||||
const runId = randomUUID();
|
const runId = randomUUID();
|
||||||
const runningRun: ScheduleRun = {
|
const runningRun: ScheduleRun = {
|
||||||
id: runId,
|
id: runId,
|
||||||
scheduledFor: schedule.nextRunAt ?? now.toISOString(),
|
scheduledFor: manual ? now.toISOString() : (schedule.nextRunAt ?? now.toISOString()),
|
||||||
startedAt: now.toISOString(),
|
startedAt: now.toISOString(),
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
status: "running",
|
status: "running",
|
||||||
@@ -315,6 +334,7 @@ export class ScheduleService {
|
|||||||
agentId: result.agentId,
|
agentId: result.agentId,
|
||||||
output: result.output,
|
output: result.output,
|
||||||
error: null,
|
error: null,
|
||||||
|
manual,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.finishRun({
|
await this.finishRun({
|
||||||
@@ -324,6 +344,7 @@ export class ScheduleService {
|
|||||||
agentId: null,
|
agentId: null,
|
||||||
output: null,
|
output: null,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
manual,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.runningScheduleIds.delete(schedule.id);
|
this.runningScheduleIds.delete(schedule.id);
|
||||||
@@ -337,6 +358,7 @@ export class ScheduleService {
|
|||||||
agentId: string | null;
|
agentId: string | null;
|
||||||
output: string | null;
|
output: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
manual: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const schedule = await this.inspect(params.scheduleId);
|
const schedule = await this.inspect(params.scheduleId);
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
@@ -359,7 +381,9 @@ export class ScheduleService {
|
|||||||
updatedAt: now.toISOString(),
|
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);
|
updated = completeSchedule(updated, now);
|
||||||
} else if (updated.status === "paused") {
|
} else if (updated.status === "paused") {
|
||||||
updated = {
|
updated = {
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export interface CreateScheduleInput {
|
|||||||
target: ScheduleTarget;
|
target: ScheduleTarget;
|
||||||
maxRuns?: number | null;
|
maxRuns?: number | null;
|
||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
|
runOnCreate?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleExecutionResult {
|
export interface ScheduleExecutionResult {
|
||||||
|
|||||||
@@ -2130,6 +2130,23 @@ export class Session {
|
|||||||
return this.handleChatReadRequest(msg);
|
return this.handleChatReadRequest(msg);
|
||||||
case "chat/wait":
|
case "chat/wait":
|
||||||
return this.handleChatWaitRequest(msg);
|
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":
|
case "schedule/create":
|
||||||
return this.handleScheduleCreateRequest(msg);
|
return this.handleScheduleCreateRequest(msg);
|
||||||
case "schedule/list":
|
case "schedule/list":
|
||||||
@@ -2144,16 +2161,8 @@ export class Session {
|
|||||||
return this.handleScheduleResumeRequest(msg);
|
return this.handleScheduleResumeRequest(msg);
|
||||||
case "schedule/delete":
|
case "schedule/delete":
|
||||||
return this.handleScheduleDeleteRequest(msg);
|
return this.handleScheduleDeleteRequest(msg);
|
||||||
case "loop/run":
|
case "schedule/run-once":
|
||||||
return this.handleLoopRunRequest(msg);
|
return this.handleScheduleRunOnceRequest(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:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -8395,7 +8404,8 @@ export class Session {
|
|||||||
| "schedule/logs"
|
| "schedule/logs"
|
||||||
| "schedule/pause"
|
| "schedule/pause"
|
||||||
| "schedule/resume"
|
| "schedule/resume"
|
||||||
| "schedule/delete";
|
| "schedule/delete"
|
||||||
|
| "schedule/run-once";
|
||||||
}
|
}
|
||||||
>,
|
>,
|
||||||
error: unknown,
|
error: unknown,
|
||||||
@@ -8428,6 +8438,7 @@ export class Session {
|
|||||||
target,
|
target,
|
||||||
maxRuns: request.maxRuns,
|
maxRuns: request.maxRuns,
|
||||||
expiresAt: request.expiresAt,
|
expiresAt: request.expiresAt,
|
||||||
|
runOnCreate: request.runOnCreate,
|
||||||
});
|
});
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "schedule/create/response",
|
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(
|
private emitLoopRpcError(
|
||||||
request: Extract<
|
request: Extract<
|
||||||
SessionInboundMessage,
|
SessionInboundMessage,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
SchedulePauseRequestSchema,
|
SchedulePauseRequestSchema,
|
||||||
ScheduleResumeRequestSchema,
|
ScheduleResumeRequestSchema,
|
||||||
ScheduleDeleteRequestSchema,
|
ScheduleDeleteRequestSchema,
|
||||||
|
ScheduleRunOnceRequestSchema,
|
||||||
ScheduleCreateResponseSchema,
|
ScheduleCreateResponseSchema,
|
||||||
ScheduleListResponseSchema,
|
ScheduleListResponseSchema,
|
||||||
ScheduleInspectResponseSchema,
|
ScheduleInspectResponseSchema,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
SchedulePauseResponseSchema,
|
SchedulePauseResponseSchema,
|
||||||
ScheduleResumeResponseSchema,
|
ScheduleResumeResponseSchema,
|
||||||
ScheduleDeleteResponseSchema,
|
ScheduleDeleteResponseSchema,
|
||||||
|
ScheduleRunOnceResponseSchema,
|
||||||
} from "../server/schedule/rpc-schemas.js";
|
} from "../server/schedule/rpc-schemas.js";
|
||||||
import {
|
import {
|
||||||
LoopRunRequestSchema,
|
LoopRunRequestSchema,
|
||||||
@@ -1765,6 +1767,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SchedulePauseRequestSchema,
|
SchedulePauseRequestSchema,
|
||||||
ScheduleResumeRequestSchema,
|
ScheduleResumeRequestSchema,
|
||||||
ScheduleDeleteRequestSchema,
|
ScheduleDeleteRequestSchema,
|
||||||
|
ScheduleRunOnceRequestSchema,
|
||||||
LoopRunRequestSchema,
|
LoopRunRequestSchema,
|
||||||
LoopListRequestSchema,
|
LoopListRequestSchema,
|
||||||
LoopInspectRequestSchema,
|
LoopInspectRequestSchema,
|
||||||
@@ -3378,6 +3381,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SchedulePauseResponseSchema,
|
SchedulePauseResponseSchema,
|
||||||
ScheduleResumeResponseSchema,
|
ScheduleResumeResponseSchema,
|
||||||
ScheduleDeleteResponseSchema,
|
ScheduleDeleteResponseSchema,
|
||||||
|
ScheduleRunOnceResponseSchema,
|
||||||
LoopRunResponseSchema,
|
LoopRunResponseSchema,
|
||||||
LoopListResponseSchema,
|
LoopListResponseSchema,
|
||||||
LoopInspectResponseSchema,
|
LoopInspectResponseSchema,
|
||||||
@@ -3483,6 +3487,7 @@ export type ScheduleLogsResponse = z.infer<typeof ScheduleLogsResponseSchema>;
|
|||||||
export type SchedulePauseResponse = z.infer<typeof SchedulePauseResponseSchema>;
|
export type SchedulePauseResponse = z.infer<typeof SchedulePauseResponseSchema>;
|
||||||
export type ScheduleResumeResponse = z.infer<typeof ScheduleResumeResponseSchema>;
|
export type ScheduleResumeResponse = z.infer<typeof ScheduleResumeResponseSchema>;
|
||||||
export type ScheduleDeleteResponse = z.infer<typeof ScheduleDeleteResponseSchema>;
|
export type ScheduleDeleteResponse = z.infer<typeof ScheduleDeleteResponseSchema>;
|
||||||
|
export type ScheduleRunOnceResponse = z.infer<typeof ScheduleRunOnceResponseSchema>;
|
||||||
export type LoopRunResponse = z.infer<typeof LoopRunResponseSchema>;
|
export type LoopRunResponse = z.infer<typeof LoopRunResponseSchema>;
|
||||||
export type LoopListResponse = z.infer<typeof LoopListResponseSchema>;
|
export type LoopListResponse = z.infer<typeof LoopListResponseSchema>;
|
||||||
export type LoopInspectResponse = z.infer<typeof LoopInspectResponseSchema>;
|
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 SchedulePauseRequest = z.infer<typeof SchedulePauseRequestSchema>;
|
||||||
export type ScheduleResumeRequest = z.infer<typeof ScheduleResumeRequestSchema>;
|
export type ScheduleResumeRequest = z.infer<typeof ScheduleResumeRequestSchema>;
|
||||||
export type ScheduleDeleteRequest = z.infer<typeof ScheduleDeleteRequestSchema>;
|
export type ScheduleDeleteRequest = z.infer<typeof ScheduleDeleteRequestSchema>;
|
||||||
|
export type ScheduleRunOnceRequest = z.infer<typeof ScheduleRunOnceRequestSchema>;
|
||||||
export type LoopRunRequest = z.infer<typeof LoopRunRequestSchema>;
|
export type LoopRunRequest = z.infer<typeof LoopRunRequestSchema>;
|
||||||
export type LoopListRequest = z.infer<typeof LoopListRequestSchema>;
|
export type LoopListRequest = z.infer<typeof LoopListRequestSchema>;
|
||||||
export type LoopInspectRequest = z.infer<typeof LoopInspectRequestSchema>;
|
export type LoopInspectRequest = z.infer<typeof LoopInspectRequestSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user