mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-09 05:19:32 +08:00
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:
committed by
Peter Steinberger
parent
0bb0dfc9bc
commit
ab9f06f4ff
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Web UI: add Agents dashboard for managing agent files, tools, skills, models, channels, and cron jobs.
|
||||
- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.
|
||||
- Cron: default isolated jobs to announce delivery; accept ISO 8601 `schedule.at` in tool inputs.
|
||||
- Cron: delete one-shot jobs after success by default; add `--keep-after-run` for CLI.
|
||||
- Memory: implement the opt-in QMD backend for workspace memory. (#3160) Thanks @vignesh07.
|
||||
- Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman.
|
||||
- Config: allow setting a default subagent thinking level via `agents.defaults.subagents.thinking` (and per-agent `agents.list[].subagents.thinking`). (#7372) Thanks @tyler6204.
|
||||
|
||||
@@ -86,7 +86,8 @@ Think of a cron job as: **when** to run + **what** to do.
|
||||
- Main session → `payload.kind = "systemEvent"`
|
||||
- Isolated session → `payload.kind = "agentTurn"`
|
||||
|
||||
Optional: `deleteAfterRun: true` removes successful one-shot jobs from the store.
|
||||
Optional: one-shot jobs (`schedule.kind = "at"`) delete after success by default. Set
|
||||
`deleteAfterRun: false` to keep them (they will disable after success).
|
||||
|
||||
## Concepts
|
||||
|
||||
@@ -102,7 +103,7 @@ A cron job is a stored record with:
|
||||
|
||||
Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs).
|
||||
In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility.
|
||||
Jobs can optionally auto-delete after a successful one-shot run via `deleteAfterRun: true`.
|
||||
One-shot jobs auto-delete after success by default; set `deleteAfterRun: false` to keep them.
|
||||
|
||||
### Schedules
|
||||
|
||||
@@ -289,7 +290,8 @@ Notes:
|
||||
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
|
||||
- `atMs` and `everyMs` are epoch milliseconds.
|
||||
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
|
||||
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun`, `delivery`, `isolation`.
|
||||
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
|
||||
`delivery`, `isolation`.
|
||||
- `wakeMode` defaults to `"next-heartbeat"` when omitted.
|
||||
|
||||
### cron.update params
|
||||
|
||||
@@ -20,6 +20,8 @@ Note: isolated `cron add` jobs default to `--announce` delivery. Use `--deliver`
|
||||
or `--no-deliver` to keep output internal. To opt into the legacy main-summary path, pass
|
||||
`--post-prefix` (or other `--post-*` options) without delivery flags.
|
||||
|
||||
Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after-run` to keep them.
|
||||
|
||||
## Common edits
|
||||
|
||||
Update delivery settings without changing the message:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 : "";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,9 +21,9 @@ export const DEFAULT_CRON_FORM: CronFormState = {
|
||||
everyUnit: "minutes",
|
||||
cronExpr: "0 7 * * *",
|
||||
cronTz: "",
|
||||
sessionTarget: "main",
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payloadKind: "systemEvent",
|
||||
payloadKind: "agentTurn",
|
||||
payloadText: "",
|
||||
deliveryMode: "announce",
|
||||
deliveryChannel: "last",
|
||||
|
||||
Reference in New Issue
Block a user