Skip to content

Commit b8adc11

Browse files
authored
feat(cron): support command jobs
Add command-backed cron jobs with timeout-safe process-tree cleanup for shell wrappers. Ensures POSIX command jobs run in a killable process group, adds Windows tree cleanup fallback handling, and covers timeout cleanup behind sh -lc.
1 parent 181238f commit b8adc11

45 files changed

Lines changed: 2092 additions & 105 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/automation/cron-jobs.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,33 @@ This fires ~5–6 times per month instead of 0–1 times per month. OpenClaw use
122122
</Accordion>
123123
</AccordionGroup>
124124

125+
### Command payloads
126+
127+
Use command payloads for deterministic scripts that should run inside the Gateway scheduler without starting a model-backed isolated agent turn. Command jobs execute on the Gateway host, capture stdout/stderr, record the run in cron history, and reuse the same `announce`, `webhook`, and `none` delivery modes as isolated jobs.
128+
129+
<Note>
130+
Command cron is an operator-admin Gateway automation surface, not an agent
131+
`tools.exec` call. Creating, updating, removing, or manually running cron jobs
132+
requires `operator.admin`; scheduled command runs later execute inside the
133+
Gateway process as that admin-authored automation. Agent exec policy such as
134+
`tools.exec.mode`, approval prompts, and per-agent tool allowlists governs
135+
model-visible exec tools, not command cron payloads.
136+
</Note>
137+
138+
```bash
139+
openclaw cron create "*/15 * * * *" \
140+
--name "Queue depth probe" \
141+
--command "scripts/check-queue.sh" \
142+
--command-cwd "/srv/app" \
143+
--announce \
144+
--channel telegram \
145+
--to "-1001234567890"
146+
```
147+
148+
`--command <shell>` stores `argv: ["sh", "-lc", <shell>]`. Use `--command-argv '["node","scripts/report.mjs"]'` when you want exact argv execution without shell parsing. Optional `--command-env KEY=VALUE`, `--command-input`, `--timeout-seconds`, `--no-output-timeout-seconds`, and `--output-max-bytes` fields control the process environment, stdin, and output bounds.
149+
150+
If stdout is non-empty, that text is the delivered result. If stdout is empty and stderr is non-empty, stderr is delivered. If both streams are present, cron delivers a small `stdout:` / `stderr:` block. A zero exit code records the run as `ok`; non-zero exit, signal, timeout, or no-output timeout records `error` and can trigger failure alerts. A command that prints only `NO_REPLY` uses the normal cron silent-token suppression and posts nothing back to chat.
151+
125152
### Payload options for isolated jobs
126153

127154
<ParamField path="--message" type="string" required>
@@ -246,6 +273,17 @@ Failure notifications follow a separate destination path:
246273
--webhook "https://example.invalid/openclaw/cron"
247274
```
248275
</Tab>
276+
<Tab title="Command output">
277+
```bash
278+
openclaw cron create "*/15 * * * *" \
279+
--name "Queue depth probe" \
280+
--command "scripts/check-queue.sh" \
281+
--command-cwd "/srv/app" \
282+
--announce \
283+
--channel telegram \
284+
--to "-1001234567890"
285+
```
286+
</Tab>
249287
</Tabs>
250288

251289
## Webhooks

docs/cli/cron.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,27 @@ openclaw cron create "0 18 * * 1-5" \
3434
--webhook "https://example.invalid/openclaw/cron"
3535
```
3636

37+
Use `--command` for deterministic shell-style jobs that should run inside OpenClaw cron without starting an isolated agent/model run:
38+
39+
<Note>
40+
Command cron jobs are admin-authored Gateway automation. Creating, editing,
41+
removing, or manually running them requires `operator.admin`; the scheduled run
42+
later executes in the Gateway process, not as an agent `tools.exec` tool call.
43+
`tools.exec.*` and exec approvals still govern model-visible exec tools.
44+
</Note>
45+
46+
```bash
47+
openclaw cron create "*/15 * * * *" \
48+
--name "Queue depth probe" \
49+
--command "scripts/check-queue.sh" \
50+
--command-cwd "/srv/app" \
51+
--announce \
52+
--channel telegram \
53+
--to "-1001234567890"
54+
```
55+
56+
`--command <shell>` stores `argv: ["sh", "-lc", <shell>]`. Use `--command-argv '["node","scripts/report.mjs"]'` for exact argv execution. Command jobs capture stdout/stderr, record normal cron history, and route output through the same `announce`, `webhook`, or `none` delivery modes as isolated jobs. A command that prints only `NO_REPLY` is suppressed.
57+
3758
## Sessions
3859

3960
`--session` accepts `main`, `isolated`, `current`, or `session:<id>`.
@@ -92,6 +113,10 @@ Note: isolated cron runs treat run-level agent failures as job errors even when
92113
no reply payload is produced, so model/provider failures still increment error
93114
counters and trigger failure notifications.
94115

116+
Command cron jobs do not start an isolated agent turn. A zero exit code records
117+
`ok`; non-zero exit, signal, timeout, or no-output timeout records `error` and
118+
can trigger the same failure notification path.
119+
95120
If an isolated run times out before the first model request, `openclaw cron show`
96121
and `openclaw cron runs` include a phase-specific error such as
97122
`setup timed out before runner start` or
@@ -252,6 +277,21 @@ openclaw cron create "0 7 * * *" \
252277

253278
`--light-context` applies to isolated agent-turn jobs only. For cron runs, lightweight mode keeps bootstrap context empty instead of injecting the full workspace bootstrap set.
254279

280+
Create a command job with exact argv, cwd, env, stdin, and output limits:
281+
282+
```bash
283+
openclaw cron create "*/30 * * * *" \
284+
--name "Position export" \
285+
--command-argv '["node","scripts/export-position.mjs"]' \
286+
--command-cwd "/srv/app" \
287+
--command-env "NODE_ENV=production" \
288+
--command-input '{"mode":"summary"}' \
289+
--timeout-seconds 120 \
290+
--no-output-timeout-seconds 30 \
291+
--output-max-bytes 65536 \
292+
--webhook "https://example.invalid/openclaw/cron"
293+
```
294+
255295
## Common admin commands
256296

257297
Manual run and inspection:

packages/gateway-protocol/src/cron-validators.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,36 @@ describe("cron protocol validators", () => {
5454
).toBe(true);
5555
});
5656

57+
it("accepts command cron payloads", () => {
58+
expect(
59+
validateCronAddParams({
60+
...minimalAddParams,
61+
sessionTarget: "isolated",
62+
payload: {
63+
kind: "command",
64+
argv: ["sh", "-lc", "echo ok"],
65+
cwd: "/srv/example",
66+
env: { FOO: "bar" },
67+
input: "stdin",
68+
timeoutSeconds: 30,
69+
noOutputTimeoutSeconds: 5,
70+
outputMaxBytes: 4096,
71+
},
72+
}),
73+
).toBe(true);
74+
expect(
75+
validateCronUpdateParams({
76+
id: "job-1",
77+
patch: {
78+
payload: {
79+
kind: "command",
80+
argv: ["sh", "-lc", "echo updated"],
81+
},
82+
},
83+
}),
84+
).toBe(true);
85+
});
86+
5787
it("rejects add params when required scheduling fields are missing", () => {
5888
const { wakeMode: _wakeMode, ...withoutWakeMode } = minimalAddParams;
5989
expect(validateCronAddParams(withoutWakeMode)).toBe(false);

packages/gateway-protocol/src/schema/cron.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ function cronAgentTurnPayloadSchema(params: { message: TSchema; toolsAllow: TSch
2727
);
2828
}
2929

30+
/** Builds command payload variants while preserving create/patch argv optionality. */
31+
function cronCommandPayloadSchema(params: { argv: TSchema }) {
32+
return Type.Object(
33+
{
34+
kind: Type.Literal("command"),
35+
argv: params.argv,
36+
cwd: Type.Optional(Type.String({ minLength: 1 })),
37+
env: Type.Optional(Type.Record(Type.String({ minLength: 1 }), Type.String())),
38+
input: Type.Optional(Type.String()),
39+
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
40+
noOutputTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
41+
outputMaxBytes: Type.Optional(Type.Integer({ minimum: 1 })),
42+
},
43+
{ additionalProperties: false },
44+
);
45+
}
46+
3047
/** Session target accepted by cron jobs. */
3148
const CronSessionTargetSchema = Type.Union([
3249
Type.Literal("main"),
@@ -212,6 +229,9 @@ export const CronPayloadSchema = Type.Union([
212229
message: NonEmptyString,
213230
toolsAllow: Type.Array(Type.String()),
214231
}),
232+
cronCommandPayloadSchema({
233+
argv: Type.Array(NonEmptyString, { minItems: 1 }),
234+
}),
215235
]);
216236

217237
/** Partial cron payload for job updates. */
@@ -227,6 +247,9 @@ export const CronPayloadPatchSchema = Type.Union([
227247
message: Type.Optional(NonEmptyString),
228248
toolsAllow: Type.Union([Type.Array(Type.String()), Type.Null()]),
229249
}),
250+
cronCommandPayloadSchema({
251+
argv: Type.Optional(Type.Array(NonEmptyString, { minItems: 1 })),
252+
}),
230253
]);
231254

232255
/** Failure alert policy for repeated cron run failures. */

src/agents/tools/cron-tool.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,23 @@ describe("cron tool", () => {
637637
expect(params?.failureAlert).toBe(false);
638638
});
639639

640+
it("rejects command payloads from the agent cron tool on add", async () => {
641+
const tool = createTestCronTool();
642+
643+
await expect(
644+
tool.execute("call-command-add", {
645+
action: "add",
646+
job: {
647+
name: "command",
648+
schedule: { at: new Date(123).toISOString() },
649+
sessionTarget: "isolated",
650+
payload: { kind: "command", argv: ["sh", "-lc", "echo ok"] },
651+
},
652+
}),
653+
).rejects.toThrow("cron command payloads cannot be created or edited");
654+
expect(callGatewayMock).not.toHaveBeenCalled();
655+
});
656+
640657
it.each([
641658
["delivery.channel", { channel: " ", to: "chat-1" }],
642659
["delivery.to", { mode: "announce", channel: "telegram", to: " \t" }],
@@ -1458,6 +1475,21 @@ describe("cron tool", () => {
14581475
expect(params?.patch?.failureAlert).toBe(false);
14591476
});
14601477

1478+
it("rejects command payloads from the agent cron tool on update", async () => {
1479+
const tool = createTestCronTool();
1480+
1481+
await expect(
1482+
tool.execute("call-command-update", {
1483+
action: "update",
1484+
id: "job-4",
1485+
patch: {
1486+
payload: { kind: "command", argv: ["sh", "-lc", "echo ok"] },
1487+
},
1488+
}),
1489+
).rejects.toThrow("cron command payloads cannot be created or edited");
1490+
expect(callGatewayMock).not.toHaveBeenCalled();
1491+
});
1492+
14611493
it("recovers flattened payload patch params for update action", async () => {
14621494
callGatewayMock.mockResolvedValueOnce({ ok: true });
14631495

src/agents/tools/cron-tool.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,18 @@ function stripExistingContext(text: string) {
339339
return text.slice(0, index).trim();
340340
}
341341

342+
function assertNoCronCommandPayload(value: unknown): void {
343+
if (!isRecord(value)) {
344+
return;
345+
}
346+
const payload = isRecord(value.payload) ? value.payload : undefined;
347+
if (payload?.kind === "command") {
348+
throw new Error(
349+
"cron command payloads cannot be created or edited through the agent cron tool; use the CLI or Gateway API.",
350+
);
351+
}
352+
}
353+
342354
function truncateText(input: string, maxLen: number) {
343355
if (input.length <= maxLen) {
344356
return input;
@@ -657,6 +669,7 @@ Use jobId canonical; id accepted compat. contextMessages (0-10) adds previous me
657669
throw new Error("job required");
658670
}
659671
const canonicalJob = canonicalizeCronToolObject(params.job as Record<string, unknown>);
672+
assertNoCronCommandPayload(canonicalJob);
660673
assertCronDeliveryInputNonBlankFields(canonicalJob.delivery);
661674
const job =
662675
normalizeCronJobCreate(canonicalJob, {
@@ -774,6 +787,7 @@ Use jobId canonical; id accepted compat. contextMessages (0-10) adds previous me
774787
const canonicalPatch = canonicalizeCronToolObject(
775788
params.patch as Record<string, unknown>,
776789
);
790+
assertNoCronCommandPayload(canonicalPatch);
777791
assertCronDeliveryInputNonBlankFields(canonicalPatch.delivery);
778792
const patch = normalizeCronJobPatch(canonicalPatch) ?? canonicalPatch;
779793
if (recoveredFlatPatch && isEmptyRecoveredCronPatch(patch)) {

0 commit comments

Comments
 (0)