diff --git a/CHANGELOG.md b/CHANGELOG.md index b4a67a4fb4..4daa929457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 669d56cdfb..87b6964763 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -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 diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 5793280dab..09ea72edb3 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -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: diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index daea270e26..cedbdc57bb 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -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 diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 8f3530438b..abe196eeca 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -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(); diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 6439980d64..d85fee814b 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -68,8 +68,9 @@ export function registerCronAddCommand(cron: Command) { .option("--description ", "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 ", "Agent id for this job") - .option("--session ", "Session target (main|isolated)", "main") + .option("--session ", "Session target (main|isolated)") .option("--wake ", "Wake mode (now|next-heartbeat)", "next-heartbeat") .option("--at ", "Run once at time (ISO) or +duration (e.g. 20m)") .option("--every ", "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, diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index 8a83a1cb9a..6b8a9e10c5 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -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; + + expect(normalized.deleteAfterRun).toBe(true); + }); + it("normalizes delivery mode and channel", () => { const normalized = normalizeCronJobCreate({ name: "delivery", diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index c722232770..5533edc0a4 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -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 : ""; diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index 3bf9f2f5f0..5fe376a938 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -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", diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 5525176985..e538462124 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -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, diff --git a/ui/src/ui/app-defaults.ts b/ui/src/ui/app-defaults.ts index 3cf74a3045..eae000ae05 100644 --- a/ui/src/ui/app-defaults.ts +++ b/ui/src/ui/app-defaults.ts @@ -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",