feat(cron): enhance one-shot job behavior and CLI options

- Default one-shot jobs to delete after success, improving job management.
- Introduced `--keep-after-run` CLI option to allow users to retain one-shot jobs post-execution.
- Updated documentation to clarify default behaviors and new options for one-shot jobs.
- Adjusted cron job creation logic to ensure consistent handling of delete options.
- Enhanced tests to validate new behaviors and ensure reliability.

This update streamlines the handling of one-shot jobs, providing users with more control over job persistence and execution outcomes.
This commit is contained in:
Tyler Yust
2026-02-03 15:00:03 -08:00
committed by Peter Steinberger
parent 0bb0dfc9bc
commit ab9f06f4ff
11 changed files with 126 additions and 21 deletions

View File

@@ -206,6 +206,7 @@ LEGACY DELIVERY (payload, only when delivery is omitted):
CRITICAL CONSTRAINTS:
- sessionTarget="main" REQUIRES payload.kind="systemEvent"
- sessionTarget="isolated" REQUIRES payload.kind="agentTurn"
Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event.
WAKE MODES (for wake action):
- "next-heartbeat" (default): Wake on next heartbeat

View File

@@ -95,6 +95,67 @@ describe("cron cli", () => {
expect(params?.delivery?.mode).toBe("announce");
});
it("infers sessionTarget from payload when --session is omitted", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
await program.parseAsync(
["cron", "add", "--name", "Main reminder", "--cron", "* * * * *", "--system-event", "hi"],
{ from: "user" },
);
let addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
let params = addCall?.[2] as { sessionTarget?: string; payload?: { kind?: string } };
expect(params?.sessionTarget).toBe("main");
expect(params?.payload?.kind).toBe("systemEvent");
callGatewayFromCli.mockClear();
await program.parseAsync(
["cron", "add", "--name", "Isolated task", "--cron", "* * * * *", "--message", "hello"],
{ from: "user" },
);
addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
params = addCall?.[2] as { sessionTarget?: string; payload?: { kind?: string } };
expect(params?.sessionTarget).toBe("isolated");
expect(params?.payload?.kind).toBe("agentTurn");
});
it("supports --keep-after-run on cron add", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
await program.parseAsync(
[
"cron",
"add",
"--name",
"Keep me",
"--at",
"20m",
"--session",
"main",
"--system-event",
"hello",
"--keep-after-run",
],
{ from: "user" },
);
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
const params = addCall?.[2] as { deleteAfterRun?: boolean };
expect(params?.deleteAfterRun).toBe(false);
});
it("sends agent id on cron add", async () => {
callGatewayFromCli.mockClear();

View File

@@ -68,8 +68,9 @@ export function registerCronAddCommand(cron: Command) {
.option("--description <text>", "Optional description")
.option("--disabled", "Create job disabled", false)
.option("--delete-after-run", "Delete one-shot job after it succeeds", false)
.option("--keep-after-run", "Keep one-shot job after it succeeds", false)
.option("--agent <id>", "Agent id for this job")
.option("--session <target>", "Session target (main|isolated)", "main")
.option("--session <target>", "Session target (main|isolated)")
.option("--wake <mode>", "Wake mode (now|next-heartbeat)", "next-heartbeat")
.option("--at <when>", "Run once at time (ISO) or +duration (e.g. 20m)")
.option("--every <duration>", "Run every duration (e.g. 10m, 1h)")
@@ -131,12 +132,6 @@ export function registerCronAddCommand(cron: Command) {
};
})();
const sessionTargetRaw = typeof opts.session === "string" ? opts.session : "main";
const sessionTarget = sessionTargetRaw.trim() || "main";
if (sessionTarget !== "main" && sessionTarget !== "isolated") {
throw new Error("--session must be main or isolated");
}
const wakeModeRaw = typeof opts.wake === "string" ? opts.wake : "next-heartbeat";
const wakeMode = wakeModeRaw.trim() || "next-heartbeat";
if (wakeMode !== "now" && wakeMode !== "next-heartbeat") {
@@ -181,6 +176,23 @@ export function registerCronAddCommand(cron: Command) {
};
})();
const optionSource =
typeof cmd?.getOptionValueSource === "function"
? (name: string) => cmd.getOptionValueSource(name)
: () => undefined;
const sessionSource = optionSource("session");
const sessionTargetRaw = typeof opts.session === "string" ? opts.session.trim() : "";
const inferredSessionTarget = payload.kind === "agentTurn" ? "isolated" : "main";
const sessionTarget =
sessionSource === "cli" ? sessionTargetRaw || "" : inferredSessionTarget;
if (sessionTarget !== "main" && sessionTarget !== "isolated") {
throw new Error("--session must be main or isolated");
}
if (opts.deleteAfterRun && opts.keepAfterRun) {
throw new Error("Choose --delete-after-run or --keep-after-run, not both");
}
if (sessionTarget === "main" && payload.kind !== "systemEvent") {
throw new Error("Main jobs require --system-event (systemEvent).");
}
@@ -194,10 +206,6 @@ export function registerCronAddCommand(cron: Command) {
throw new Error("--announce/--deliver/--no-deliver require --session isolated.");
}
const optionSource =
typeof cmd?.getOptionValueSource === "function"
? (name: string) => cmd.getOptionValueSource(name)
: () => undefined;
const hasLegacyPostConfig =
optionSource("postPrefix") === "cli" ||
optionSource("postMode") === "cli" ||
@@ -262,7 +270,7 @@ export function registerCronAddCommand(cron: Command) {
name,
description,
enabled: !opts.disabled,
deleteAfterRun: Boolean(opts.deleteAfterRun),
deleteAfterRun: opts.deleteAfterRun ? true : opts.keepAfterRun ? false : undefined,
agentId,
schedule,
sessionTarget,

View File

@@ -111,6 +111,22 @@ describe("normalizeCronJobCreate", () => {
expect(schedule.atMs).toBe(Date.parse("2026-01-12T18:00:00Z"));
});
it("defaults deleteAfterRun for one-shot schedules", () => {
const normalized = normalizeCronJobCreate({
name: "default delete",
enabled: true,
schedule: { at: "2026-01-12T18:00:00Z" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: "hi",
},
}) as unknown as Record<string, unknown>;
expect(normalized.deleteAfterRun).toBe(true);
});
it("normalizes delivery mode and channel", () => {
const normalized = normalizeCronJobCreate({
name: "delivery",

View File

@@ -172,6 +172,14 @@ export function normalizeCronJobInput(
next.sessionTarget = "isolated";
}
}
if (
"schedule" in next &&
isRecord(next.schedule) &&
next.schedule.kind === "at" &&
!("deleteAfterRun" in next)
) {
next.deleteAfterRun = true;
}
const hasDelivery = "delivery" in next && next.delivery !== undefined;
const payload = isRecord(next.payload) ? next.payload : null;
const payloadKind = payload && typeof payload.kind === "string" ? payload.kind : "";

View File

@@ -36,7 +36,7 @@ describe("CronService", () => {
vi.useRealTimers();
});
it("runs a one-shot main job and disables it after success", async () => {
it("runs a one-shot main job and disables it after success when requested", async () => {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -55,6 +55,7 @@ describe("CronService", () => {
const job = await cron.add({
name: "one-shot hello",
enabled: true,
deleteAfterRun: false,
schedule: { kind: "at", atMs },
sessionTarget: "main",
wakeMode: "now",
@@ -79,7 +80,7 @@ describe("CronService", () => {
await store.cleanup();
});
it("runs a one-shot job and deletes it after success when requested", async () => {
it("runs a one-shot job and deletes it after success by default", async () => {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -98,7 +99,6 @@ describe("CronService", () => {
const job = await cron.add({
name: "one-shot delete",
enabled: true,
deleteAfterRun: true,
schedule: { kind: "at", atMs },
sessionTarget: "main",
wakeMode: "now",

View File

@@ -97,13 +97,19 @@ export function nextWakeAtMs(state: CronServiceState) {
export function createJob(state: CronServiceState, input: CronJobCreate): CronJob {
const now = state.deps.nowMs();
const id = crypto.randomUUID();
const deleteAfterRun =
typeof input.deleteAfterRun === "boolean"
? input.deleteAfterRun
: input.schedule.kind === "at"
? true
: undefined;
const job: CronJob = {
id,
agentId: normalizeOptionalAgentId(input.agentId),
name: normalizeRequiredName(input.name),
description: normalizeOptionalText(input.description),
enabled: input.enabled,
deleteAfterRun: input.deleteAfterRun,
deleteAfterRun,
createdAtMs: now,
updatedAtMs: now,
schedule: input.schedule,