Skip to content

Commit 4d08580

Browse files
committed
fix(cron): expose per-job fallbacks in CLI
1 parent 1fc04ac commit 4d08580

12 files changed

Lines changed: 151 additions & 7 deletions

File tree

docs/automation/cron-jobs.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ If stdout is non-empty, that text is the delivered result. If stdout is empty an
157157
<ParamField path="--model" type="string">
158158
Model override; uses the selected allowed model for the job.
159159
</ParamField>
160+
<ParamField path="--fallbacks" type="string">
161+
Per-job fallback model list, for example `--fallbacks openrouter/gpt-4.1-mini,openai/gpt-5`. Pass `--fallbacks ""` for a strict run with no fallbacks.
162+
</ParamField>
163+
<ParamField path="--clear-fallbacks" type="boolean">
164+
On `cron edit`, removes the per-job fallback override so the job follows configured fallback precedence. Cannot be combined with `--fallbacks`.
165+
</ParamField>
160166
<ParamField path="--clear-model" type="boolean">
161167
On `cron edit`, removes the per-job model override so the job follows normal cron model-selection precedence (a stored cron-session override if set, otherwise the agent/default model). Cannot be combined with `--model`.
162168
</ParamField>
@@ -478,7 +484,7 @@ Model override note:
478484
- API `cron.update` payload patches can set `model: null` to clear a stored job model override.
479485
- `openclaw cron edit <job-id> --clear-model` clears that override from the CLI (same effect as the `model: null` patch) and cannot be combined with `--model`.
480486
- Configured fallback chains still apply because cron `--model` is a job primary, not a session `/model` override.
481-
- Payload `fallbacks` replaces configured fallbacks for that job; `fallbacks: []` disables fallback and makes the run strict.
487+
- `openclaw cron add|edit --fallbacks ...` sets payload `fallbacks`, replacing configured fallbacks for that job; `--fallbacks ""` disables fallback and makes the run strict. `openclaw cron edit <job-id> --clear-fallbacks` clears the per-job override.
482488
- A plain `--model` with no explicit or configured fallback list does not fall through to the agent primary as a silent extra retry target.
483489

484490
</Note>

docs/cli/cron.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ Use `--due` when you want the manual command to run only if the job is currently
170170

171171
## Models
172172

173-
`cron add|edit --model <ref>` selects an allowed model for the job. `cron edit <job-id> --clear-model` removes the per-job model override so the job follows normal cron model-selection precedence (a stored cron-session override if present, otherwise the agent/default model); it cannot be combined with `--model`.
173+
`cron add|edit --model <ref>` selects an allowed model for the job. `cron add|edit --fallbacks <list>` sets per-job fallback models, for example `--fallbacks openrouter/gpt-4.1-mini,openai/gpt-5`; pass `--fallbacks ""` for a strict run with no fallbacks. `cron edit <job-id> --clear-fallbacks` removes the per-job fallback override. `cron edit <job-id> --clear-model` removes the per-job model override so the job follows normal cron model-selection precedence (a stored cron-session override if present, otherwise the agent/default model); it cannot be combined with `--model`.
174174

175175
<Warning>
176176
If the model is not allowed or cannot be resolved, cron fails the run with an explicit validation error instead of falling back to the job's agent or default model selection.
@@ -180,7 +180,7 @@ Cron `--model` is a **job primary**, not a chat-session `/model` override. That
180180

181181
- Configured model fallbacks still apply when the selected job model fails.
182182
- Per-job payload `fallbacks` replaces the configured fallback list when present.
183-
- An empty per-job fallback list (`fallbacks: []` in the job payload/API) makes the cron run strict.
183+
- An empty per-job fallback list (`--fallbacks ""` or `fallbacks: []` in the job payload/API) makes the cron run strict.
184184
- When a job has `--model` but no fallback list is configured, OpenClaw passes an explicit empty fallback override so the agent primary is not appended as a hidden retry target.
185185
- Local-provider preflight checks walk configured fallbacks before marking a cron run `skipped`.
186186

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@ import { NonEmptyString } from "./primitives.js";
1313
function cronAgentTurnPayloadSchema(params: {
1414
message: TSchema;
1515
model: TSchema;
16+
fallbacks: TSchema;
1617
toolsAllow: TSchema;
1718
}) {
1819
return Type.Object(
1920
{
2021
kind: Type.Literal("agentTurn"),
2122
message: params.message,
2223
model: Type.Optional(params.model),
23-
fallbacks: Type.Optional(Type.Array(Type.String())),
24+
fallbacks: Type.Optional(params.fallbacks),
2425
thinking: Type.Optional(Type.String()),
2526
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
2627
allowUnsafeExternalContent: Type.Optional(Type.Boolean()),
@@ -232,6 +233,7 @@ export const CronPayloadSchema = Type.Union([
232233
cronAgentTurnPayloadSchema({
233234
message: NonEmptyString,
234235
model: Type.String(),
236+
fallbacks: Type.Array(Type.String()),
235237
toolsAllow: Type.Array(Type.String()),
236238
}),
237239
cronCommandPayloadSchema({
@@ -251,6 +253,7 @@ export const CronPayloadPatchSchema = Type.Union([
251253
cronAgentTurnPayloadSchema({
252254
message: Type.Optional(NonEmptyString),
253255
model: Type.Union([Type.String(), Type.Null()]),
256+
fallbacks: Type.Union([Type.Array(Type.String()), Type.Null()]),
254257
toolsAllow: Type.Union([Type.Array(Type.String()), Type.Null()]),
255258
}),
256259
cronCommandPayloadSchema({

src/cli/cron-cli.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ type CronUpdatePatch = {
6868
input?: string;
6969
message?: string;
7070
model?: string;
71+
fallbacks?: string[] | null;
7172
thinking?: string;
7273
lightContext?: boolean;
7374
timeoutSeconds?: number;
@@ -97,6 +98,7 @@ type CronAddParams = {
9798
input?: string;
9899
message?: string;
99100
model?: string;
101+
fallbacks?: string[];
100102
thinking?: string;
101103
lightContext?: boolean;
102104
timeoutSeconds?: number;
@@ -1044,6 +1046,23 @@ describe("cron cli", () => {
10441046
expect(params?.payload?.toolsAllow).toEqual(["exec", "read", "write"]);
10451047
});
10461048

1049+
it("sets fallback models on cron add", async () => {
1050+
const params = await runCronAddAndGetParams([
1051+
"--name",
1052+
"Fallbacks",
1053+
"--cron",
1054+
"* * * * *",
1055+
"--session",
1056+
"isolated",
1057+
"--message",
1058+
"hello",
1059+
"--fallbacks",
1060+
"openrouter/gpt-4.1-mini openai/gpt-5",
1061+
]);
1062+
1063+
expect(params?.payload?.fallbacks).toEqual(["openrouter/gpt-4.1-mini", "openai/gpt-5"]);
1064+
});
1065+
10471066
it.each([
10481067
{
10491068
label: "omits empty model and thinking",
@@ -1074,6 +1093,27 @@ describe("cron cli", () => {
10741093
expect(patch?.patch?.payload?.toolsAllow).toEqual(["exec", "read", "write"]);
10751094
});
10761095

1096+
it("sets fallback models on cron edit", async () => {
1097+
const patch = await runCronEditAndGetPatch([
1098+
"--fallbacks",
1099+
"openrouter/gpt-4.1-mini,openai/gpt-5",
1100+
]);
1101+
1102+
expect(patch?.patch?.payload?.fallbacks).toEqual(["openrouter/gpt-4.1-mini", "openai/gpt-5"]);
1103+
});
1104+
1105+
it("sets strict empty fallbacks on cron edit", async () => {
1106+
const patch = await runCronEditAndGetPatch(["--fallbacks", ""]);
1107+
1108+
expect(patch?.patch?.payload?.fallbacks).toEqual([]);
1109+
});
1110+
1111+
it("clears fallback models on cron edit", async () => {
1112+
const patch = await runCronEditAndGetPatch(["--clear-fallbacks"]);
1113+
1114+
expect(patch?.patch?.payload?.fallbacks).toBeNull();
1115+
});
1116+
10771117
it("sets and clears agent id on cron edit", async () => {
10781118
await runCronCommand(["cron", "edit", "job-1", "--agent", " Ops ", "--message", "hello"]);
10791119

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
handleCronCliError,
2323
parseCronCommandArgv,
2424
parseCronCommandEnv,
25+
parseCronFallbacks,
2526
parseCronToolsAllow,
2627
printCronJson,
2728
printCronList,
@@ -124,6 +125,7 @@ export function registerCronAddCommand(cron: Command) {
124125
"Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)",
125126
)
126127
.option("--model <model>", "Model override for agent jobs (provider/model or alias)")
128+
.option("--fallbacks <list>", "Fallback model list for agent jobs")
127129
.option("--timeout-seconds <n>", "Timeout seconds for agent or command jobs")
128130
.option("--no-output-timeout-seconds <n>", "No-output timeout seconds for command jobs")
129131
.option("--output-max-bytes <n>", "Maximum captured stdout/stderr bytes for command jobs")
@@ -254,6 +256,7 @@ export function registerCronAddCommand(cron: Command) {
254256
kind: "agentTurn" as const,
255257
message,
256258
model: normalizeOptionalString(opts.model),
259+
fallbacks: parseCronFallbacks(opts.fallbacks),
257260
thinking: normalizeOptionalString(opts.thinking),
258261
timeoutSeconds:
259262
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
getCronChannelOptions,
1919
parseCronCommandArgv,
2020
parseCronCommandEnv,
21+
parseCronFallbacks,
2122
parseCronToolsAllow,
2223
parseDurationMs,
2324
warnIfCronSchedulerDisabled,
@@ -112,6 +113,8 @@ export function registerCronEditCommand(cron: Command) {
112113
"Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)",
113114
)
114115
.option("--model <model>", "Model override for agent jobs")
116+
.option("--fallbacks <list>", "Fallback model list for agent jobs")
117+
.option("--clear-fallbacks", "Remove per-job fallback override", false)
115118
.option(
116119
"--clear-model",
117120
"Remove the per-job model override (restore normal cron model precedence)",
@@ -289,6 +292,10 @@ export function registerCronEditCommand(cron: Command) {
289292
throw new Error("Use --model or --clear-model, not both");
290293
}
291294
const thinking = normalizeOptionalString(opts.thinking);
295+
const fallbacks = parseCronFallbacks(opts.fallbacks);
296+
if (typeof opts.fallbacks === "string" && opts.clearFallbacks) {
297+
throw new Error("Use --fallbacks or --clear-fallbacks, not both");
298+
}
292299
const toolsAllow = parseCronToolsAllow(opts.tools);
293300
const rawTimeoutSeconds =
294301
opts.timeoutSeconds === undefined ? undefined : String(opts.timeoutSeconds).trim();
@@ -360,6 +367,8 @@ export function registerCronEditCommand(cron: Command) {
360367
!hasCommandSpecificPayloadField &&
361368
typeof opts.message !== "string" &&
362369
!model &&
370+
typeof opts.fallbacks !== "string" &&
371+
!opts.clearFallbacks &&
363372
!thinking &&
364373
typeof opts.lightContext !== "boolean" &&
365374
typeof opts.tools !== "string" &&
@@ -373,6 +382,8 @@ export function registerCronEditCommand(cron: Command) {
373382
typeof opts.message === "string" ||
374383
Boolean(model) ||
375384
Boolean(opts.clearModel) ||
385+
typeof opts.fallbacks === "string" ||
386+
Boolean(opts.clearFallbacks) ||
376387
Boolean(thinking) ||
377388
(hasTimeoutSeconds &&
378389
!hasCommandSpecificPayloadField &&
@@ -405,6 +416,8 @@ export function registerCronEditCommand(cron: Command) {
405416
} else {
406417
assignIf(payload, "model", model, Boolean(model));
407418
}
419+
assignIf(payload, "fallbacks", fallbacks, typeof opts.fallbacks === "string");
420+
assignIf(payload, "fallbacks", null, Boolean(opts.clearFallbacks));
408421
assignIf(payload, "thinking", thinking, Boolean(thinking));
409422
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
410423
assignIf(

src/cli/cron-cli/shared.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,21 @@ export function parseCronToolsAllow(input: unknown): string[] | undefined {
265265
return tools.length > 0 ? tools : undefined;
266266
}
267267

268+
export function parseCronFallbacks(input: unknown): string[] | undefined {
269+
if (input === undefined) {
270+
return undefined;
271+
}
272+
const raw = Array.isArray(input)
273+
? input.map((value) => String(value)).join(" ")
274+
: typeof input === "string"
275+
? input
276+
: "";
277+
return raw
278+
.split(/[,\s]+/u)
279+
.map((fallback) => normalizeOptionalString(fallback))
280+
.filter((fallback): fallback is string => Boolean(fallback));
281+
}
282+
268283
/**
269284
* Parse a one-shot `--at` value into an ISO string (UTC).
270285
*

src/cron/normalize.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,20 @@ describe("normalizeCronJobPatch", () => {
875875
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
876876
});
877877

878+
it("preserves null fallback lists so patches can clear the fallback override", () => {
879+
const normalized = normalizeCronJobPatch({
880+
payload: {
881+
kind: "agentTurn",
882+
fallbacks: null,
883+
},
884+
}) as unknown as Record<string, unknown>;
885+
886+
const payload = normalized.payload as Record<string, unknown>;
887+
expect(payload.kind).toBe("agentTurn");
888+
expect(payload.fallbacks).toBeNull();
889+
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
890+
});
891+
878892
it("promotes implicit text payloads with agentTurn hints to agentTurn patches", () => {
879893
const normalized = normalizeCronJobPatch({
880894
payload: {

src/cron/normalize.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ function coercePayload(payload: UnknownRecord) {
210210
}
211211
}
212212
if ("fallbacks" in next) {
213-
const fallbacks = normalizeTrimmedStringArray(next.fallbacks);
213+
const fallbacks = normalizeTrimmedStringArray(next.fallbacks, { allowNull: true });
214214
if (fallbacks !== undefined) {
215215
next.fallbacks = fallbacks;
216216
} else {

src/cron/service.jobs.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,53 @@ describe("applyJobPatch", () => {
319319
}
320320
});
321321

322+
it("clears agentTurn payload.fallbacks when patch requests null", () => {
323+
const job = createIsolatedAgentTurnJob("job-fallbacks-clear", {
324+
mode: "announce",
325+
channel: "telegram",
326+
});
327+
job.payload = {
328+
kind: "agentTurn",
329+
message: "do it",
330+
fallbacks: ["openrouter/gpt-4.1-mini"],
331+
};
332+
333+
applyJobPatch(job, {
334+
payload: {
335+
kind: "agentTurn",
336+
message: "do it",
337+
fallbacks: null,
338+
},
339+
});
340+
341+
expect(job.payload.kind).toBe("agentTurn");
342+
if (job.payload.kind === "agentTurn") {
343+
expect(job.payload.fallbacks).toBeUndefined();
344+
}
345+
});
346+
347+
it("omits null payload.fallbacks when replacing a non-agent payload", () => {
348+
const job = createIsolatedAgentTurnJob("job-fallbacks-kind-switch", {
349+
mode: "announce",
350+
channel: "telegram",
351+
});
352+
job.payload = { kind: "systemEvent", text: "tick" };
353+
354+
applyJobPatch(job, {
355+
payload: {
356+
kind: "agentTurn",
357+
message: "do it",
358+
fallbacks: null,
359+
},
360+
});
361+
362+
const payload = job.payload as CronJob["payload"];
363+
expect(payload.kind).toBe("agentTurn");
364+
if (payload.kind === "agentTurn") {
365+
expect(payload.fallbacks).toBeUndefined();
366+
}
367+
});
368+
322369
it("persists agentTurn payload.toolsAllow updates when editing existing jobs", () => {
323370
const job = createIsolatedAgentTurnJob("job-tools", {
324371
mode: "announce",

0 commit comments

Comments
 (0)