From 0bb0dfc9bccbab8473a409ae03ab7892920080f9 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Tue, 3 Feb 2026 14:21:38 -0800 Subject: [PATCH] feat(cron): default isolated jobs to announce delivery and enhance scheduling options - Updated isolated cron jobs to default to `announce` delivery mode, improving user experience. - Enhanced scheduling options to accept ISO 8601 timestamps for `schedule.at`, while still supporting epoch milliseconds. - Refined documentation to clarify delivery modes and scheduling formats. - Adjusted related CLI commands and UI components to reflect these changes, ensuring consistency across the platform. - Improved handling of legacy delivery fields for backward compatibility. This update streamlines the configuration of isolated jobs, making it easier for users to manage job outputs and schedules. --- CHANGELOG.md | 1 + docs/automation/cron-jobs.md | 25 ++++--- docs/automation/cron-vs-heartbeat.md | 16 ++--- docs/cli/cron.md | 4 ++ docs/web/control-ui.md | 2 +- src/agents/tools/cron-tool.ts | 6 +- src/cli/cron-cli.test.ts | 30 +++++++++ src/cli/cron-cli/register.cron-add.ts | 93 +++++++++++++++++---------- src/cron/normalize.test.ts | 46 +++++++++++++ src/cron/normalize.ts | 28 ++++++++ ui/src/ui/app-defaults.ts | 4 +- ui/src/ui/controllers/cron.ts | 7 +- ui/src/ui/views/cron.ts | 2 +- 13 files changed, 202 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc6df6a3..b4a67a4fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai - Feishu: add Feishu/Lark plugin support + docs. (#7313) Thanks @jiulingyun (openclaw-cn). - 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. - 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 58ff8b14b..669d56cdf 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -23,7 +23,7 @@ cron is the mechanism. - Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules. - Two execution styles: - **Main session**: enqueue a system event, then run on the next heartbeat. - - **Isolated**: run a dedicated agent turn in `cron:`, with a delivery mode (legacy summary, announce, full output, or none). + - **Isolated**: run a dedicated agent turn in `cron:`, with delivery (announce by default, full output or none; legacy main summary still supported). - Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. ## Quick start (actionable) @@ -108,7 +108,7 @@ Jobs can optionally auto-delete after a successful one-shot run via `deleteAfter Cron supports three schedule kinds: -- `at`: one-shot timestamp (ms since epoch). Gateway accepts ISO 8601 and coerces to UTC. +- `at`: one-shot timestamp. Prefer ISO 8601 via `schedule.at`; `atMs` (epoch ms) is also accepted. - `every`: fixed interval (ms). - `cron`: 5-field cron expression with optional IANA timezone. @@ -136,12 +136,13 @@ Key behaviors: - Prompt is prefixed with `[cron: ]` for traceability. - Each run starts a **fresh session id** (no prior conversation carry-over). -- Legacy behavior (no `delivery` field): a summary is posted to the main session (prefix `Cron`, configurable). +- Default behavior: if `delivery` is omitted, isolated jobs announce a summary immediately (`delivery.mode = "announce"`), unless legacy isolation settings or legacy payload delivery fields are provided. +- Legacy behavior: jobs with legacy isolation settings, legacy payload delivery fields, or older stored jobs without `delivery` post a summary to the main session (prefix `Cron`, configurable). - `delivery.mode` (isolated-only) chooses what happens instead of the legacy summary: - `announce`: subagent-style summary delivered immediately to a chat. - `deliver`: full agent output delivered immediately to a chat. - `none`: internal only (no main summary, no delivery). -- `wakeMode: "now"` triggers an immediate heartbeat after posting the **legacy** summary. +- `wakeMode: "now"` only triggers an immediate heartbeat when using the legacy main-summary path. Use isolated jobs for noisy, frequent, or "background chores" that shouldn't spam your main chat history. @@ -166,6 +167,9 @@ Delivery config (isolated jobs only): - `delivery.to`: channel-specific target (phone/chat/channel id). - `delivery.bestEffort`: avoid failing the job if delivery fails (deliver mode). +If `delivery` is omitted for isolated jobs, OpenClaw defaults to `announce` unless legacy isolation +settings are present. + Legacy delivery fields (still accepted when `delivery` is omitted): - `payload.deliver`: `true` to send output to a channel target. @@ -179,7 +183,7 @@ Isolation options (only for `session=isolated`): - `postToMainMode`: `summary` (default) or `full`. - `postToMainMaxChars`: max chars when `postToMainMode=full` (default 8000). -Note: isolation post-to-main settings apply to legacy jobs (no `delivery` field). If `delivery` is set, the legacy summary is skipped. +Note: setting isolation post-to-main options opts into the legacy main-summary path (no `delivery` field). If `delivery` is set, the legacy summary is skipped. ### Model and thinking overrides @@ -211,7 +215,7 @@ Delivery config is only valid for isolated jobs (`sessionTarget: "isolated"`). If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main session’s “last route” (the last place the agent replied). -Legacy behavior (no `delivery` field): +Legacy behavior (no `delivery` field with legacy isolation settings or older jobs): - If `payload.to` is set, cron auto-delivers the agent’s final output even if `payload.deliver` is omitted. - Use `payload.deliver: true` when you want last-route delivery without an explicit `to`. @@ -240,8 +244,8 @@ Prefixed targets like `telegram:...` / `telegram:group:...` are also accepted: ## JSON schema for tool calls Use these shapes when calling Gateway `cron.*` tools directly (agent tool calls or RPC). -CLI flags accept human durations like `20m`, but tool calls use epoch milliseconds for -`atMs` and `everyMs` (ISO timestamps are accepted for `at` times). +CLI flags accept human durations like `20m`, but tool calls should use an ISO 8601 string +for `schedule.at` (preferred) or epoch milliseconds for `atMs` and `everyMs`. ### cron.add params @@ -250,7 +254,7 @@ One-shot, main session job (system event): ```json { "name": "Reminder", - "schedule": { "kind": "at", "atMs": 1738262400000 }, + "schedule": { "kind": "at", "at": "2026-02-01T16:00:00Z" }, "sessionTarget": "main", "wakeMode": "now", "payload": { "kind": "systemEvent", "text": "Reminder text" }, @@ -281,7 +285,8 @@ Recurring, isolated job with delivery: Notes: -- `schedule.kind`: `at` (`atMs`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`). +- `schedule.kind`: `at` (`at` or `atMs`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`). +- `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`. diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index cc22a63ae..197a0a3fd 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -90,7 +90,7 @@ Cron jobs run at **exact times** and can run in isolated sessions without affect - **Exact timing**: 5-field cron expressions with timezone support. - **Session isolation**: Runs in `cron:` without polluting main history. - **Model overrides**: Use a cheaper or more powerful model per job. -- **Delivery control**: Choose `announce` (summary), `deliver` (full output), or `none`. Legacy jobs still post a summary to main by default. +- **Delivery control**: Isolated jobs default to `announce` (summary); choose `deliver` (full output) or `none` as needed. Legacy jobs still post a summary to main. - **Immediate delivery**: Announce/deliver modes post directly without waiting for heartbeat. - **No agent context needed**: Runs even if main session is idle or compacted. - **One-shot support**: `--at` for precise future timestamps. @@ -215,13 +215,13 @@ See [Lobster](/tools/lobster) for full usage and examples. Both heartbeat and cron can interact with the main session, but differently: -| | Heartbeat | Cron (main) | Cron (isolated) | -| ------- | ------------------------------- | ------------------------ | ---------------------- | -| Session | Main | Main (via system event) | `cron:` | -| History | Shared | Shared | Fresh each run | -| Context | Full | Full | None (starts clean) | -| Model | Main session model | Main session model | Can override | -| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Summary posted to main | +| | Heartbeat | Cron (main) | Cron (isolated) | +| ------- | ------------------------------- | ------------------------ | -------------------------- | +| Session | Main | Main (via system event) | `cron:` | +| History | Shared | Shared | Fresh each run | +| Context | Full | Full | None (starts clean) | +| Model | Main session model | Main session model | Can override | +| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) | ### When to use main session cron diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 02d6a4afb..5793280da 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -16,6 +16,10 @@ Related: Tip: run `openclaw cron --help` for the full command surface. +Note: isolated `cron add` jobs default to `--announce` delivery. Use `--deliver` for full output +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. + ## Common edits Update delivery settings without changing the message: diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 5438c4592..458b14f4d 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -81,7 +81,7 @@ you revoke it with `openclaw devices revoke --device --role `. See Cron jobs panel notes: -- For isolated jobs, choose a delivery mode: legacy main summary, announce summary, deliver full output, or none. +- For isolated jobs, delivery defaults to announce summary. You can switch to legacy main summary, deliver full output, or none. - Channel/target fields appear when announce or deliver is selected. ## Chat behavior diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 774efff47..daea270e2 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -181,12 +181,15 @@ JOB SCHEMA (for add action): SCHEDULE TYPES (schedule.kind): - "at": One-shot at absolute time - { "kind": "at", "atMs": } + { "kind": "at", "at": "" } // preferred + { "kind": "at", "atMs": } // also accepted - "every": Recurring interval { "kind": "every", "everyMs": , "anchorMs": } - "cron": Cron expression { "kind": "cron", "expr": "", "tz": "" } +ISO timestamps without an explicit timezone are treated as UTC. + PAYLOAD TYPES (payload.kind): - "systemEvent": Injects text as system event into session { "kind": "systemEvent", "text": "" } @@ -195,6 +198,7 @@ PAYLOAD TYPES (payload.kind): DELIVERY (isolated-only, top-level): { "mode": "none|announce|deliver", "channel": "", "to": "", "bestEffort": } + - Default for isolated agentTurn jobs (when delivery omitted): "announce" LEGACY DELIVERY (payload, only when delivery is omitted): { "deliver": , "channel": "", "to": "", "bestEffortDeliver": } diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 3fa71e930..8f3530438 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -65,6 +65,36 @@ describe("cron cli", () => { expect(params?.payload?.thinking).toBe("low"); }); + it("defaults isolated cron add to announce delivery", async () => { + callGatewayFromCli.mockClear(); + + const { registerCronCli } = await import("./cron-cli.js"); + const program = new Command(); + program.exitOverride(); + registerCronCli(program); + + await program.parseAsync( + [ + "cron", + "add", + "--name", + "Daily", + "--cron", + "* * * * *", + "--session", + "isolated", + "--message", + "hello", + ], + { from: "user" }, + ); + + const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add"); + const params = addCall?.[2] as { delivery?: { mode?: string } }; + + expect(params?.delivery?.mode).toBe("announce"); + }); + 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 31a0260d6..6439980d6 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -100,7 +100,7 @@ export function registerCronAddCommand(cron: Command) { ) .option("--post-max-chars ", "Max chars when --post-mode=full (default 8000)", "8000") .option("--json", "Output JSON", false) - .action(async (opts: GatewayRpcOpts & Record) => { + .action(async (opts: GatewayRpcOpts & Record, cmd?: Command) => { try { const schedule = (() => { const at = typeof opts.at === "string" ? opts.at : ""; @@ -148,6 +148,14 @@ export function registerCronAddCommand(cron: Command) { ? sanitizeAgentId(opts.agent.trim()) : undefined; + const hasAnnounce = Boolean(opts.announce); + const hasDeliver = opts.deliver === true; + const hasNoDeliver = opts.deliver === false; + const deliveryFlagCount = [hasAnnounce, hasDeliver, hasNoDeliver].filter(Boolean).length; + if (deliveryFlagCount > 1) { + throw new Error("Choose at most one of --announce, --deliver, or --no-deliver"); + } + const payload = (() => { const systemEvent = typeof opts.systemEvent === "string" ? opts.systemEvent.trim() : ""; const message = typeof opts.message === "string" ? opts.message.trim() : ""; @@ -159,15 +167,6 @@ export function registerCronAddCommand(cron: Command) { return { kind: "systemEvent" as const, text: systemEvent }; } const timeoutSeconds = parsePositiveIntOrUndefined(opts.timeoutSeconds); - const hasAnnounce = Boolean(opts.announce); - const hasDeliver = opts.deliver === true; - const hasNoDeliver = opts.deliver === false; - const deliveryFlagCount = [hasAnnounce, hasDeliver, hasNoDeliver].filter( - Boolean, - ).length; - if (deliveryFlagCount > 1) { - throw new Error("Choose at most one of --announce, --deliver, or --no-deliver"); - } return { kind: "agentTurn" as const, message, @@ -179,15 +178,6 @@ export function registerCronAddCommand(cron: Command) { : undefined, timeoutSeconds: timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined, - channel: - typeof opts.channel === "string" && opts.channel.trim() - ? opts.channel.trim() - : "last", - to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined, - bestEffortDeliver: - !hasAnnounce && !hasDeliver && !hasNoDeliver && opts.bestEffortDeliver - ? true - : undefined, }; })(); @@ -204,8 +194,30 @@ 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" || + optionSource("postMaxChars") === "cli"; + + if ( + hasLegacyPostConfig && + (sessionTarget !== "isolated" || payload.kind !== "agentTurn") + ) { + throw new Error( + "--post-prefix/--post-mode/--post-max-chars require --session isolated.", + ); + } + + if (hasLegacyPostConfig && (hasAnnounce || hasDeliver || hasNoDeliver)) { + throw new Error("Choose legacy main-summary options or a delivery mode (not both)."); + } + const isolation = - sessionTarget === "isolated" + sessionTarget === "isolated" && hasLegacyPostConfig ? { postToMainPrefix: typeof opts.postPrefix === "string" && opts.postPrefix.trim() @@ -216,12 +228,25 @@ export function registerCronAddCommand(cron: Command) { ? opts.postMode : undefined, postToMainMaxChars: - typeof opts.postMaxChars === "string" && /^\d+$/.test(opts.postMaxChars) + opts.postMode === "full" && + typeof opts.postMaxChars === "string" && + /^\d+$/.test(opts.postMaxChars) ? Number.parseInt(opts.postMaxChars, 10) : undefined, } : undefined; + const deliveryMode = + sessionTarget === "isolated" && payload.kind === "agentTurn" && !hasLegacyPostConfig + ? hasAnnounce + ? "announce" + : hasDeliver + ? "deliver" + : hasNoDeliver + ? "none" + : "announce" + : undefined; + const nameRaw = typeof opts.name === "string" ? opts.name : ""; const name = nameRaw.trim(); if (!name) { @@ -243,20 +268,18 @@ export function registerCronAddCommand(cron: Command) { sessionTarget, wakeMode, payload, - delivery: - payload.kind === "agentTurn" && - sessionTarget === "isolated" && - (opts.announce || typeof opts.deliver === "boolean") - ? { - mode: opts.announce ? "announce" : opts.deliver === true ? "deliver" : "none", - channel: - typeof opts.channel === "string" && opts.channel.trim() - ? opts.channel.trim() - : "last", - to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined, - bestEffort: opts.bestEffortDeliver ? true : undefined, - } - : undefined, + delivery: deliveryMode + ? { + mode: deliveryMode, + channel: + typeof opts.channel === "string" && opts.channel.trim() + ? opts.channel.trim() + : undefined, + to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined, + bestEffort: + deliveryMode === "deliver" && opts.bestEffortDeliver ? true : undefined, + } + : undefined, isolation, }; diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index d73a3d89e..8a83a1cb9 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -134,4 +134,50 @@ describe("normalizeCronJobCreate", () => { expect(delivery.channel).toBe("telegram"); expect(delivery.to).toBe("7200373102"); }); + + it("defaults isolated agentTurn delivery to announce", () => { + const normalized = normalizeCronJobCreate({ + name: "default-announce", + enabled: true, + schedule: { kind: "cron", expr: "* * * * *" }, + payload: { + kind: "agentTurn", + message: "hi", + }, + }) as unknown as Record; + + const delivery = normalized.delivery as Record; + expect(delivery.mode).toBe("announce"); + }); + + it("does not override explicit legacy delivery fields", () => { + const normalized = normalizeCronJobCreate({ + name: "legacy deliver", + enabled: true, + schedule: { kind: "cron", expr: "* * * * *" }, + payload: { + kind: "agentTurn", + message: "hi", + deliver: true, + to: "7200373102", + }, + }) as unknown as Record; + + expect(normalized.delivery).toBeUndefined(); + }); + + it("does not override legacy isolation settings", () => { + const normalized = normalizeCronJobCreate({ + name: "legacy isolation", + enabled: true, + schedule: { kind: "cron", expr: "* * * * *" }, + payload: { + kind: "agentTurn", + message: "hi", + }, + isolation: { postToMainPrefix: "Cron" }, + }) as unknown as Record; + + expect(normalized.delivery).toBeUndefined(); + }); }); diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 2f83b2937..c72223277 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -85,6 +85,19 @@ function coerceDelivery(delivery: UnknownRecord) { return next; } +function hasLegacyDeliveryHints(payload: UnknownRecord) { + if (typeof payload.deliver === "boolean") { + return true; + } + if (typeof payload.bestEffortDeliver === "boolean") { + return true; + } + if (typeof payload.to === "string" && payload.to.trim()) { + return true; + } + return false; +} + function unwrapJob(raw: UnknownRecord) { if (isRecord(raw.data)) { return raw.data; @@ -159,6 +172,21 @@ export function normalizeCronJobInput( next.sessionTarget = "isolated"; } } + 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 : ""; + const sessionTarget = typeof next.sessionTarget === "string" ? next.sessionTarget : ""; + const hasLegacyIsolation = isRecord(next.isolation); + const hasLegacyDelivery = payload ? hasLegacyDeliveryHints(payload) : false; + if ( + !hasDelivery && + !hasLegacyIsolation && + !hasLegacyDelivery && + sessionTarget === "isolated" && + payloadKind === "agentTurn" + ) { + next.delivery = { mode: "announce" }; + } } return next; diff --git a/ui/src/ui/app-defaults.ts b/ui/src/ui/app-defaults.ts index 79a9977c6..3cf74a304 100644 --- a/ui/src/ui/app-defaults.ts +++ b/ui/src/ui/app-defaults.ts @@ -25,9 +25,9 @@ export const DEFAULT_CRON_FORM: CronFormState = { wakeMode: "next-heartbeat", payloadKind: "systemEvent", payloadText: "", - deliveryMode: "legacy", + deliveryMode: "announce", deliveryChannel: "last", deliveryTo: "", timeoutSeconds: "", - postToMainPrefix: "", + postToMainPrefix: "Cron", }; diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 970b191d5..8b51c9a6d 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -121,6 +121,7 @@ export async function addCronJob(state: CronState) { to: state.cronForm.deliveryTo.trim() || undefined, } : undefined; + const legacyPrefix = state.cronForm.postToMainPrefix.trim() || "Cron"; const agentId = state.cronForm.agentId.trim(); const job = { name: state.cronForm.name.trim(), @@ -133,10 +134,8 @@ export async function addCronJob(state: CronState) { payload, delivery, isolation: - state.cronForm.postToMainPrefix.trim() && - state.cronForm.sessionTarget === "isolated" && - state.cronForm.deliveryMode === "legacy" - ? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() } + state.cronForm.sessionTarget === "isolated" && state.cronForm.deliveryMode === "legacy" + ? { postToMainPrefix: legacyPrefix } : undefined, }; if (!job.name) { diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index db5682ca0..979ab8820 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -212,7 +212,7 @@ export function renderCron(props: CronProps) { })} > - +