Skip to content

Commit 0bb0dfc

Browse files
tyler6204steipete
authored andcommitted
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.
1 parent 511c656 commit 0bb0dfc

13 files changed

Lines changed: 202 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai
4646
- Feishu: add Feishu/Lark plugin support + docs. (#7313) Thanks @jiulingyun (openclaw-cn).
4747
- Web UI: add Agents dashboard for managing agent files, tools, skills, models, channels, and cron jobs.
4848
- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.
49+
- Cron: default isolated jobs to announce delivery; accept ISO 8601 `schedule.at` in tool inputs.
4950
- Memory: implement the opt-in QMD backend for workspace memory. (#3160) Thanks @vignesh07.
5051
- Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman.
5152
- Config: allow setting a default subagent thinking level via `agents.defaults.subagents.thinking` (and per-agent `agents.list[].subagents.thinking`). (#7372) Thanks @tyler6204.

docs/automation/cron-jobs.md

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ cron is the mechanism.
2323
- Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules.
2424
- Two execution styles:
2525
- **Main session**: enqueue a system event, then run on the next heartbeat.
26-
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, with a delivery mode (legacy summary, announce, full output, or none).
26+
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, with delivery (announce by default, full output or none; legacy main summary still supported).
2727
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
2828

2929
## Quick start (actionable)
@@ -108,7 +108,7 @@ Jobs can optionally auto-delete after a successful one-shot run via `deleteAfter
108108

109109
Cron supports three schedule kinds:
110110

111-
- `at`: one-shot timestamp (ms since epoch). Gateway accepts ISO 8601 and coerces to UTC.
111+
- `at`: one-shot timestamp. Prefer ISO 8601 via `schedule.at`; `atMs` (epoch ms) is also accepted.
112112
- `every`: fixed interval (ms).
113113
- `cron`: 5-field cron expression with optional IANA timezone.
114114

@@ -136,12 +136,13 @@ Key behaviors:
136136

137137
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
138138
- Each run starts a **fresh session id** (no prior conversation carry-over).
139-
- Legacy behavior (no `delivery` field): a summary is posted to the main session (prefix `Cron`, configurable).
139+
- 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.
140+
- 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).
140141
- `delivery.mode` (isolated-only) chooses what happens instead of the legacy summary:
141142
- `announce`: subagent-style summary delivered immediately to a chat.
142143
- `deliver`: full agent output delivered immediately to a chat.
143144
- `none`: internal only (no main summary, no delivery).
144-
- `wakeMode: "now"` triggers an immediate heartbeat after posting the **legacy** summary.
145+
- `wakeMode: "now"` only triggers an immediate heartbeat when using the legacy main-summary path.
145146

146147
Use isolated jobs for noisy, frequent, or "background chores" that shouldn't spam
147148
your main chat history.
@@ -166,6 +167,9 @@ Delivery config (isolated jobs only):
166167
- `delivery.to`: channel-specific target (phone/chat/channel id).
167168
- `delivery.bestEffort`: avoid failing the job if delivery fails (deliver mode).
168169

170+
If `delivery` is omitted for isolated jobs, OpenClaw defaults to `announce` unless legacy isolation
171+
settings are present.
172+
169173
Legacy delivery fields (still accepted when `delivery` is omitted):
170174

171175
- `payload.deliver`: `true` to send output to a channel target.
@@ -179,7 +183,7 @@ Isolation options (only for `session=isolated`):
179183
- `postToMainMode`: `summary` (default) or `full`.
180184
- `postToMainMaxChars`: max chars when `postToMainMode=full` (default 8000).
181185

182-
Note: isolation post-to-main settings apply to legacy jobs (no `delivery` field). If `delivery` is set, the legacy summary is skipped.
186+
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.
183187

184188
### Model and thinking overrides
185189

@@ -211,7 +215,7 @@ Delivery config is only valid for isolated jobs (`sessionTarget: "isolated"`).
211215
If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main session’s
212216
“last route” (the last place the agent replied).
213217

214-
Legacy behavior (no `delivery` field):
218+
Legacy behavior (no `delivery` field with legacy isolation settings or older jobs):
215219

216220
- If `payload.to` is set, cron auto-delivers the agent’s final output even if `payload.deliver` is omitted.
217221
- 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:
240244
## JSON schema for tool calls
241245

242246
Use these shapes when calling Gateway `cron.*` tools directly (agent tool calls or RPC).
243-
CLI flags accept human durations like `20m`, but tool calls use epoch milliseconds for
244-
`atMs` and `everyMs` (ISO timestamps are accepted for `at` times).
247+
CLI flags accept human durations like `20m`, but tool calls should use an ISO 8601 string
248+
for `schedule.at` (preferred) or epoch milliseconds for `atMs` and `everyMs`.
245249

246250
### cron.add params
247251

@@ -250,7 +254,7 @@ One-shot, main session job (system event):
250254
```json
251255
{
252256
"name": "Reminder",
253-
"schedule": { "kind": "at", "atMs": 1738262400000 },
257+
"schedule": { "kind": "at", "at": "2026-02-01T16:00:00Z" },
254258
"sessionTarget": "main",
255259
"wakeMode": "now",
256260
"payload": { "kind": "systemEvent", "text": "Reminder text" },
@@ -281,7 +285,8 @@ Recurring, isolated job with delivery:
281285

282286
Notes:
283287

284-
- `schedule.kind`: `at` (`atMs`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
288+
- `schedule.kind`: `at` (`at` or `atMs`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
289+
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
285290
- `atMs` and `everyMs` are epoch milliseconds.
286291
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
287292
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun`, `delivery`, `isolation`.

docs/automation/cron-vs-heartbeat.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ Cron jobs run at **exact times** and can run in isolated sessions without affect
9090
- **Exact timing**: 5-field cron expressions with timezone support.
9191
- **Session isolation**: Runs in `cron:<jobId>` without polluting main history.
9292
- **Model overrides**: Use a cheaper or more powerful model per job.
93-
- **Delivery control**: Choose `announce` (summary), `deliver` (full output), or `none`. Legacy jobs still post a summary to main by default.
93+
- **Delivery control**: Isolated jobs default to `announce` (summary); choose `deliver` (full output) or `none` as needed. Legacy jobs still post a summary to main.
9494
- **Immediate delivery**: Announce/deliver modes post directly without waiting for heartbeat.
9595
- **No agent context needed**: Runs even if main session is idle or compacted.
9696
- **One-shot support**: `--at` for precise future timestamps.
@@ -215,13 +215,13 @@ See [Lobster](/tools/lobster) for full usage and examples.
215215

216216
Both heartbeat and cron can interact with the main session, but differently:
217217

218-
| | Heartbeat | Cron (main) | Cron (isolated) |
219-
| ------- | ------------------------------- | ------------------------ | ---------------------- |
220-
| Session | Main | Main (via system event) | `cron:<jobId>` |
221-
| History | Shared | Shared | Fresh each run |
222-
| Context | Full | Full | None (starts clean) |
223-
| Model | Main session model | Main session model | Can override |
224-
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Summary posted to main |
218+
| | Heartbeat | Cron (main) | Cron (isolated) |
219+
| ------- | ------------------------------- | ------------------------ | -------------------------- |
220+
| Session | Main | Main (via system event) | `cron:<jobId>` |
221+
| History | Shared | Shared | Fresh each run |
222+
| Context | Full | Full | None (starts clean) |
223+
| Model | Main session model | Main session model | Can override |
224+
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) |
225225

226226
### When to use main session cron
227227

docs/cli/cron.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ Related:
1616

1717
Tip: run `openclaw cron --help` for the full command surface.
1818

19+
Note: isolated `cron add` jobs default to `--announce` delivery. Use `--deliver` for full output
20+
or `--no-deliver` to keep output internal. To opt into the legacy main-summary path, pass
21+
`--post-prefix` (or other `--post-*` options) without delivery flags.
22+
1923
## Common edits
2024

2125
Update delivery settings without changing the message:

docs/web/control-ui.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ you revoke it with `openclaw devices revoke --device <id> --role <role>`. See
8181

8282
Cron jobs panel notes:
8383

84-
- For isolated jobs, choose a delivery mode: legacy main summary, announce summary, deliver full output, or none.
84+
- For isolated jobs, delivery defaults to announce summary. You can switch to legacy main summary, deliver full output, or none.
8585
- Channel/target fields appear when announce or deliver is selected.
8686

8787
## Chat behavior

src/agents/tools/cron-tool.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,15 @@ JOB SCHEMA (for add action):
181181
182182
SCHEDULE TYPES (schedule.kind):
183183
- "at": One-shot at absolute time
184-
{ "kind": "at", "atMs": <unix-ms-timestamp> }
184+
{ "kind": "at", "at": "<ISO-8601 timestamp>" } // preferred
185+
{ "kind": "at", "atMs": <unix-ms-timestamp> } // also accepted
185186
- "every": Recurring interval
186187
{ "kind": "every", "everyMs": <interval-ms>, "anchorMs": <optional-start-ms> }
187188
- "cron": Cron expression
188189
{ "kind": "cron", "expr": "<cron-expression>", "tz": "<optional-timezone>" }
189190
191+
ISO timestamps without an explicit timezone are treated as UTC.
192+
190193
PAYLOAD TYPES (payload.kind):
191194
- "systemEvent": Injects text as system event into session
192195
{ "kind": "systemEvent", "text": "<message>" }
@@ -195,6 +198,7 @@ PAYLOAD TYPES (payload.kind):
195198
196199
DELIVERY (isolated-only, top-level):
197200
{ "mode": "none|announce|deliver", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
201+
- Default for isolated agentTurn jobs (when delivery omitted): "announce"
198202
199203
LEGACY DELIVERY (payload, only when delivery is omitted):
200204
{ "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> }

src/cli/cron-cli.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,36 @@ describe("cron cli", () => {
6565
expect(params?.payload?.thinking).toBe("low");
6666
});
6767

68+
it("defaults isolated cron add to announce delivery", async () => {
69+
callGatewayFromCli.mockClear();
70+
71+
const { registerCronCli } = await import("./cron-cli.js");
72+
const program = new Command();
73+
program.exitOverride();
74+
registerCronCli(program);
75+
76+
await program.parseAsync(
77+
[
78+
"cron",
79+
"add",
80+
"--name",
81+
"Daily",
82+
"--cron",
83+
"* * * * *",
84+
"--session",
85+
"isolated",
86+
"--message",
87+
"hello",
88+
],
89+
{ from: "user" },
90+
);
91+
92+
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
93+
const params = addCall?.[2] as { delivery?: { mode?: string } };
94+
95+
expect(params?.delivery?.mode).toBe("announce");
96+
});
97+
6898
it("sends agent id on cron add", async () => {
6999
callGatewayFromCli.mockClear();
70100

src/cli/cron-cli/register.cron-add.ts

Lines changed: 58 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export function registerCronAddCommand(cron: Command) {
100100
)
101101
.option("--post-max-chars <n>", "Max chars when --post-mode=full (default 8000)", "8000")
102102
.option("--json", "Output JSON", false)
103-
.action(async (opts: GatewayRpcOpts & Record<string, unknown>) => {
103+
.action(async (opts: GatewayRpcOpts & Record<string, unknown>, cmd?: Command) => {
104104
try {
105105
const schedule = (() => {
106106
const at = typeof opts.at === "string" ? opts.at : "";
@@ -148,6 +148,14 @@ export function registerCronAddCommand(cron: Command) {
148148
? sanitizeAgentId(opts.agent.trim())
149149
: undefined;
150150

151+
const hasAnnounce = Boolean(opts.announce);
152+
const hasDeliver = opts.deliver === true;
153+
const hasNoDeliver = opts.deliver === false;
154+
const deliveryFlagCount = [hasAnnounce, hasDeliver, hasNoDeliver].filter(Boolean).length;
155+
if (deliveryFlagCount > 1) {
156+
throw new Error("Choose at most one of --announce, --deliver, or --no-deliver");
157+
}
158+
151159
const payload = (() => {
152160
const systemEvent = typeof opts.systemEvent === "string" ? opts.systemEvent.trim() : "";
153161
const message = typeof opts.message === "string" ? opts.message.trim() : "";
@@ -159,15 +167,6 @@ export function registerCronAddCommand(cron: Command) {
159167
return { kind: "systemEvent" as const, text: systemEvent };
160168
}
161169
const timeoutSeconds = parsePositiveIntOrUndefined(opts.timeoutSeconds);
162-
const hasAnnounce = Boolean(opts.announce);
163-
const hasDeliver = opts.deliver === true;
164-
const hasNoDeliver = opts.deliver === false;
165-
const deliveryFlagCount = [hasAnnounce, hasDeliver, hasNoDeliver].filter(
166-
Boolean,
167-
).length;
168-
if (deliveryFlagCount > 1) {
169-
throw new Error("Choose at most one of --announce, --deliver, or --no-deliver");
170-
}
171170
return {
172171
kind: "agentTurn" as const,
173172
message,
@@ -179,15 +178,6 @@ export function registerCronAddCommand(cron: Command) {
179178
: undefined,
180179
timeoutSeconds:
181180
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
182-
channel:
183-
typeof opts.channel === "string" && opts.channel.trim()
184-
? opts.channel.trim()
185-
: "last",
186-
to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined,
187-
bestEffortDeliver:
188-
!hasAnnounce && !hasDeliver && !hasNoDeliver && opts.bestEffortDeliver
189-
? true
190-
: undefined,
191181
};
192182
})();
193183

@@ -204,8 +194,30 @@ export function registerCronAddCommand(cron: Command) {
204194
throw new Error("--announce/--deliver/--no-deliver require --session isolated.");
205195
}
206196

197+
const optionSource =
198+
typeof cmd?.getOptionValueSource === "function"
199+
? (name: string) => cmd.getOptionValueSource(name)
200+
: () => undefined;
201+
const hasLegacyPostConfig =
202+
optionSource("postPrefix") === "cli" ||
203+
optionSource("postMode") === "cli" ||
204+
optionSource("postMaxChars") === "cli";
205+
206+
if (
207+
hasLegacyPostConfig &&
208+
(sessionTarget !== "isolated" || payload.kind !== "agentTurn")
209+
) {
210+
throw new Error(
211+
"--post-prefix/--post-mode/--post-max-chars require --session isolated.",
212+
);
213+
}
214+
215+
if (hasLegacyPostConfig && (hasAnnounce || hasDeliver || hasNoDeliver)) {
216+
throw new Error("Choose legacy main-summary options or a delivery mode (not both).");
217+
}
218+
207219
const isolation =
208-
sessionTarget === "isolated"
220+
sessionTarget === "isolated" && hasLegacyPostConfig
209221
? {
210222
postToMainPrefix:
211223
typeof opts.postPrefix === "string" && opts.postPrefix.trim()
@@ -216,12 +228,25 @@ export function registerCronAddCommand(cron: Command) {
216228
? opts.postMode
217229
: undefined,
218230
postToMainMaxChars:
219-
typeof opts.postMaxChars === "string" && /^\d+$/.test(opts.postMaxChars)
231+
opts.postMode === "full" &&
232+
typeof opts.postMaxChars === "string" &&
233+
/^\d+$/.test(opts.postMaxChars)
220234
? Number.parseInt(opts.postMaxChars, 10)
221235
: undefined,
222236
}
223237
: undefined;
224238

239+
const deliveryMode =
240+
sessionTarget === "isolated" && payload.kind === "agentTurn" && !hasLegacyPostConfig
241+
? hasAnnounce
242+
? "announce"
243+
: hasDeliver
244+
? "deliver"
245+
: hasNoDeliver
246+
? "none"
247+
: "announce"
248+
: undefined;
249+
225250
const nameRaw = typeof opts.name === "string" ? opts.name : "";
226251
const name = nameRaw.trim();
227252
if (!name) {
@@ -243,20 +268,18 @@ export function registerCronAddCommand(cron: Command) {
243268
sessionTarget,
244269
wakeMode,
245270
payload,
246-
delivery:
247-
payload.kind === "agentTurn" &&
248-
sessionTarget === "isolated" &&
249-
(opts.announce || typeof opts.deliver === "boolean")
250-
? {
251-
mode: opts.announce ? "announce" : opts.deliver === true ? "deliver" : "none",
252-
channel:
253-
typeof opts.channel === "string" && opts.channel.trim()
254-
? opts.channel.trim()
255-
: "last",
256-
to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined,
257-
bestEffort: opts.bestEffortDeliver ? true : undefined,
258-
}
259-
: undefined,
271+
delivery: deliveryMode
272+
? {
273+
mode: deliveryMode,
274+
channel:
275+
typeof opts.channel === "string" && opts.channel.trim()
276+
? opts.channel.trim()
277+
: undefined,
278+
to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined,
279+
bestEffort:
280+
deliveryMode === "deliver" && opts.bestEffortDeliver ? true : undefined,
281+
}
282+
: undefined,
260283
isolation,
261284
};
262285

0 commit comments

Comments
 (0)