Skip to content

Commit ab9f06f

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

File tree

11 files changed

+126
-21
lines changed

11 files changed

+126
-21
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
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.
4949
- Cron: default isolated jobs to announce delivery; accept ISO 8601 `schedule.at` in tool inputs.
50+
- Cron: delete one-shot jobs after success by default; add `--keep-after-run` for CLI.
5051
- Memory: implement the opt-in QMD backend for workspace memory. (#3160) Thanks @vignesh07.
5152
- Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman.
5253
- 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: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ Think of a cron job as: **when** to run + **what** to do.
8686
- Main session → `payload.kind = "systemEvent"`
8787
- Isolated session → `payload.kind = "agentTurn"`
8888

89-
Optional: `deleteAfterRun: true` removes successful one-shot jobs from the store.
89+
Optional: one-shot jobs (`schedule.kind = "at"`) delete after success by default. Set
90+
`deleteAfterRun: false` to keep them (they will disable after success).
9091

9192
## Concepts
9293

@@ -102,7 +103,7 @@ A cron job is a stored record with:
102103

103104
Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs).
104105
In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility.
105-
Jobs can optionally auto-delete after a successful one-shot run via `deleteAfterRun: true`.
106+
One-shot jobs auto-delete after success by default; set `deleteAfterRun: false` to keep them.
106107

107108
### Schedules
108109

@@ -289,7 +290,8 @@ Notes:
289290
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
290291
- `atMs` and `everyMs` are epoch milliseconds.
291292
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
292-
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun`, `delivery`, `isolation`.
293+
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
294+
`delivery`, `isolation`.
293295
- `wakeMode` defaults to `"next-heartbeat"` when omitted.
294296

295297
### cron.update params

docs/cli/cron.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Note: isolated `cron add` jobs default to `--announce` delivery. Use `--deliver`
2020
or `--no-deliver` to keep output internal. To opt into the legacy main-summary path, pass
2121
`--post-prefix` (or other `--post-*` options) without delivery flags.
2222

23+
Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after-run` to keep them.
24+
2325
## Common edits
2426

2527
Update delivery settings without changing the message:

src/agents/tools/cron-tool.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ LEGACY DELIVERY (payload, only when delivery is omitted):
206206
CRITICAL CONSTRAINTS:
207207
- sessionTarget="main" REQUIRES payload.kind="systemEvent"
208208
- sessionTarget="isolated" REQUIRES payload.kind="agentTurn"
209+
Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event.
209210
210211
WAKE MODES (for wake action):
211212
- "next-heartbeat" (default): Wake on next heartbeat

src/cli/cron-cli.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,67 @@ describe("cron cli", () => {
9595
expect(params?.delivery?.mode).toBe("announce");
9696
});
9797

98+
it("infers sessionTarget from payload when --session is omitted", async () => {
99+
callGatewayFromCli.mockClear();
100+
101+
const { registerCronCli } = await import("./cron-cli.js");
102+
const program = new Command();
103+
program.exitOverride();
104+
registerCronCli(program);
105+
106+
await program.parseAsync(
107+
["cron", "add", "--name", "Main reminder", "--cron", "* * * * *", "--system-event", "hi"],
108+
{ from: "user" },
109+
);
110+
111+
let addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
112+
let params = addCall?.[2] as { sessionTarget?: string; payload?: { kind?: string } };
113+
expect(params?.sessionTarget).toBe("main");
114+
expect(params?.payload?.kind).toBe("systemEvent");
115+
116+
callGatewayFromCli.mockClear();
117+
118+
await program.parseAsync(
119+
["cron", "add", "--name", "Isolated task", "--cron", "* * * * *", "--message", "hello"],
120+
{ from: "user" },
121+
);
122+
123+
addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
124+
params = addCall?.[2] as { sessionTarget?: string; payload?: { kind?: string } };
125+
expect(params?.sessionTarget).toBe("isolated");
126+
expect(params?.payload?.kind).toBe("agentTurn");
127+
});
128+
129+
it("supports --keep-after-run on cron add", async () => {
130+
callGatewayFromCli.mockClear();
131+
132+
const { registerCronCli } = await import("./cron-cli.js");
133+
const program = new Command();
134+
program.exitOverride();
135+
registerCronCli(program);
136+
137+
await program.parseAsync(
138+
[
139+
"cron",
140+
"add",
141+
"--name",
142+
"Keep me",
143+
"--at",
144+
"20m",
145+
"--session",
146+
"main",
147+
"--system-event",
148+
"hello",
149+
"--keep-after-run",
150+
],
151+
{ from: "user" },
152+
);
153+
154+
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
155+
const params = addCall?.[2] as { deleteAfterRun?: boolean };
156+
expect(params?.deleteAfterRun).toBe(false);
157+
});
158+
98159
it("sends agent id on cron add", async () => {
99160
callGatewayFromCli.mockClear();
100161

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

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,9 @@ export function registerCronAddCommand(cron: Command) {
6868
.option("--description <text>", "Optional description")
6969
.option("--disabled", "Create job disabled", false)
7070
.option("--delete-after-run", "Delete one-shot job after it succeeds", false)
71+
.option("--keep-after-run", "Keep one-shot job after it succeeds", false)
7172
.option("--agent <id>", "Agent id for this job")
72-
.option("--session <target>", "Session target (main|isolated)", "main")
73+
.option("--session <target>", "Session target (main|isolated)")
7374
.option("--wake <mode>", "Wake mode (now|next-heartbeat)", "next-heartbeat")
7475
.option("--at <when>", "Run once at time (ISO) or +duration (e.g. 20m)")
7576
.option("--every <duration>", "Run every duration (e.g. 10m, 1h)")
@@ -131,12 +132,6 @@ export function registerCronAddCommand(cron: Command) {
131132
};
132133
})();
133134

134-
const sessionTargetRaw = typeof opts.session === "string" ? opts.session : "main";
135-
const sessionTarget = sessionTargetRaw.trim() || "main";
136-
if (sessionTarget !== "main" && sessionTarget !== "isolated") {
137-
throw new Error("--session must be main or isolated");
138-
}
139-
140135
const wakeModeRaw = typeof opts.wake === "string" ? opts.wake : "next-heartbeat";
141136
const wakeMode = wakeModeRaw.trim() || "next-heartbeat";
142137
if (wakeMode !== "now" && wakeMode !== "next-heartbeat") {
@@ -181,6 +176,23 @@ export function registerCronAddCommand(cron: Command) {
181176
};
182177
})();
183178

179+
const optionSource =
180+
typeof cmd?.getOptionValueSource === "function"
181+
? (name: string) => cmd.getOptionValueSource(name)
182+
: () => undefined;
183+
const sessionSource = optionSource("session");
184+
const sessionTargetRaw = typeof opts.session === "string" ? opts.session.trim() : "";
185+
const inferredSessionTarget = payload.kind === "agentTurn" ? "isolated" : "main";
186+
const sessionTarget =
187+
sessionSource === "cli" ? sessionTargetRaw || "" : inferredSessionTarget;
188+
if (sessionTarget !== "main" && sessionTarget !== "isolated") {
189+
throw new Error("--session must be main or isolated");
190+
}
191+
192+
if (opts.deleteAfterRun && opts.keepAfterRun) {
193+
throw new Error("Choose --delete-after-run or --keep-after-run, not both");
194+
}
195+
184196
if (sessionTarget === "main" && payload.kind !== "systemEvent") {
185197
throw new Error("Main jobs require --system-event (systemEvent).");
186198
}
@@ -194,10 +206,6 @@ export function registerCronAddCommand(cron: Command) {
194206
throw new Error("--announce/--deliver/--no-deliver require --session isolated.");
195207
}
196208

197-
const optionSource =
198-
typeof cmd?.getOptionValueSource === "function"
199-
? (name: string) => cmd.getOptionValueSource(name)
200-
: () => undefined;
201209
const hasLegacyPostConfig =
202210
optionSource("postPrefix") === "cli" ||
203211
optionSource("postMode") === "cli" ||
@@ -262,7 +270,7 @@ export function registerCronAddCommand(cron: Command) {
262270
name,
263271
description,
264272
enabled: !opts.disabled,
265-
deleteAfterRun: Boolean(opts.deleteAfterRun),
273+
deleteAfterRun: opts.deleteAfterRun ? true : opts.keepAfterRun ? false : undefined,
266274
agentId,
267275
schedule,
268276
sessionTarget,

src/cron/normalize.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,22 @@ describe("normalizeCronJobCreate", () => {
111111
expect(schedule.atMs).toBe(Date.parse("2026-01-12T18:00:00Z"));
112112
});
113113

114+
it("defaults deleteAfterRun for one-shot schedules", () => {
115+
const normalized = normalizeCronJobCreate({
116+
name: "default delete",
117+
enabled: true,
118+
schedule: { at: "2026-01-12T18:00:00Z" },
119+
sessionTarget: "main",
120+
wakeMode: "next-heartbeat",
121+
payload: {
122+
kind: "systemEvent",
123+
text: "hi",
124+
},
125+
}) as unknown as Record<string, unknown>;
126+
127+
expect(normalized.deleteAfterRun).toBe(true);
128+
});
129+
114130
it("normalizes delivery mode and channel", () => {
115131
const normalized = normalizeCronJobCreate({
116132
name: "delivery",

src/cron/normalize.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,14 @@ export function normalizeCronJobInput(
172172
next.sessionTarget = "isolated";
173173
}
174174
}
175+
if (
176+
"schedule" in next &&
177+
isRecord(next.schedule) &&
178+
next.schedule.kind === "at" &&
179+
!("deleteAfterRun" in next)
180+
) {
181+
next.deleteAfterRun = true;
182+
}
175183
const hasDelivery = "delivery" in next && next.delivery !== undefined;
176184
const payload = isRecord(next.payload) ? next.payload : null;
177185
const payloadKind = payload && typeof payload.kind === "string" ? payload.kind : "";

src/cron/service.runs-one-shot-main-job-disables-it.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe("CronService", () => {
3636
vi.useRealTimers();
3737
});
3838

39-
it("runs a one-shot main job and disables it after success", async () => {
39+
it("runs a one-shot main job and disables it after success when requested", async () => {
4040
const store = await makeStorePath();
4141
const enqueueSystemEvent = vi.fn();
4242
const requestHeartbeatNow = vi.fn();
@@ -55,6 +55,7 @@ describe("CronService", () => {
5555
const job = await cron.add({
5656
name: "one-shot hello",
5757
enabled: true,
58+
deleteAfterRun: false,
5859
schedule: { kind: "at", atMs },
5960
sessionTarget: "main",
6061
wakeMode: "now",
@@ -79,7 +80,7 @@ describe("CronService", () => {
7980
await store.cleanup();
8081
});
8182

82-
it("runs a one-shot job and deletes it after success when requested", async () => {
83+
it("runs a one-shot job and deletes it after success by default", async () => {
8384
const store = await makeStorePath();
8485
const enqueueSystemEvent = vi.fn();
8586
const requestHeartbeatNow = vi.fn();
@@ -98,7 +99,6 @@ describe("CronService", () => {
9899
const job = await cron.add({
99100
name: "one-shot delete",
100101
enabled: true,
101-
deleteAfterRun: true,
102102
schedule: { kind: "at", atMs },
103103
sessionTarget: "main",
104104
wakeMode: "now",

src/cron/service/jobs.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,19 @@ export function nextWakeAtMs(state: CronServiceState) {
9797
export function createJob(state: CronServiceState, input: CronJobCreate): CronJob {
9898
const now = state.deps.nowMs();
9999
const id = crypto.randomUUID();
100+
const deleteAfterRun =
101+
typeof input.deleteAfterRun === "boolean"
102+
? input.deleteAfterRun
103+
: input.schedule.kind === "at"
104+
? true
105+
: undefined;
100106
const job: CronJob = {
101107
id,
102108
agentId: normalizeOptionalAgentId(input.agentId),
103109
name: normalizeRequiredName(input.name),
104110
description: normalizeOptionalText(input.description),
105111
enabled: input.enabled,
106-
deleteAfterRun: input.deleteAfterRun,
112+
deleteAfterRun,
107113
createdAtMs: now,
108114
updatedAtMs: now,
109115
schedule: input.schedule,

0 commit comments

Comments
 (0)