From 1662b07810eb34ebc3ff5ad927d6313cc97e5666 Mon Sep 17 00:00:00 2001
From: Yzx <53250620+849261680@users.noreply.github.com>
Date: Tue, 23 Jun 2026 03:22:20 +0800
Subject: [PATCH] fix(cron): expose per-job fallbacks in CLI (#93369)
---
docs/automation/cron-jobs.md | 8 +++-
docs/cli/cron.md | 4 +-
packages/gateway-protocol/src/schema/cron.ts | 5 ++-
src/cli/cron-cli.test.ts | 40 +++++++++++++++++
src/cli/cron-cli/register.cron-add.ts | 3 ++
src/cli/cron-cli/register.cron-edit.ts | 13 ++++++
src/cli/cron-cli/shared.ts | 15 +++++++
src/cron/normalize.test.ts | 14 ++++++
src/cron/normalize.ts | 2 +-
src/cron/service.jobs.test.ts | 47 ++++++++++++++++++++
src/cron/service/jobs.ts | 4 +-
src/cron/types.ts | 3 +-
12 files changed, 151 insertions(+), 7 deletions(-)
diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md
index 4820e1841bca..1fd50dae2583 100644
--- a/docs/automation/cron-jobs.md
+++ b/docs/automation/cron-jobs.md
@@ -157,6 +157,12 @@ If stdout is non-empty, that text is the delivered result. If stdout is empty an
Model override; uses the selected allowed model for the job.
+
+ 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.
+
+
+ On `cron edit`, removes the per-job fallback override so the job follows configured fallback precedence. Cannot be combined with `--fallbacks`.
+
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`.
@@ -478,7 +484,7 @@ Model override note:
- API `cron.update` payload patches can set `model: null` to clear a stored job model override.
- `openclaw cron edit --clear-model` clears that override from the CLI (same effect as the `model: null` patch) and cannot be combined with `--model`.
- Configured fallback chains still apply because cron `--model` is a job primary, not a session `/model` override.
-- Payload `fallbacks` replaces configured fallbacks for that job; `fallbacks: []` disables fallback and makes the run strict.
+- `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 --clear-fallbacks` clears the per-job override.
- A plain `--model` with no explicit or configured fallback list does not fall through to the agent primary as a silent extra retry target.
diff --git a/docs/cli/cron.md b/docs/cli/cron.md
index 16c5d2a77454..115c7b5be2b3 100644
--- a/docs/cli/cron.md
+++ b/docs/cli/cron.md
@@ -170,7 +170,7 @@ Use `--due` when you want the manual command to run only if the job is currently
## Models
-`cron add|edit --model [` selects an allowed model for the job. `cron edit --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`.
+`cron add|edit --model ][` selects an allowed model for the job. `cron add|edit --fallbacks ]` 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 --clear-fallbacks` removes the per-job fallback override. `cron edit --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`.
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
- Configured model fallbacks still apply when the selected job model fails.
- Per-job payload `fallbacks` replaces the configured fallback list when present.
-- An empty per-job fallback list (`fallbacks: []` in the job payload/API) makes the cron run strict.
+- An empty per-job fallback list (`--fallbacks ""` or `fallbacks: []` in the job payload/API) makes the cron run strict.
- 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.
- Local-provider preflight checks walk configured fallbacks before marking a cron run `skipped`.
diff --git a/packages/gateway-protocol/src/schema/cron.ts b/packages/gateway-protocol/src/schema/cron.ts
index 2af49501e484..f56638591c8e 100644
--- a/packages/gateway-protocol/src/schema/cron.ts
+++ b/packages/gateway-protocol/src/schema/cron.ts
@@ -13,6 +13,7 @@ import { NonEmptyString } from "./primitives.js";
function cronAgentTurnPayloadSchema(params: {
message: TSchema;
model: TSchema;
+ fallbacks: TSchema;
toolsAllow: TSchema;
}) {
return Type.Object(
@@ -20,7 +21,7 @@ function cronAgentTurnPayloadSchema(params: {
kind: Type.Literal("agentTurn"),
message: params.message,
model: Type.Optional(params.model),
- fallbacks: Type.Optional(Type.Array(Type.String())),
+ fallbacks: Type.Optional(params.fallbacks),
thinking: Type.Optional(Type.String()),
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
allowUnsafeExternalContent: Type.Optional(Type.Boolean()),
@@ -232,6 +233,7 @@ export const CronPayloadSchema = Type.Union([
cronAgentTurnPayloadSchema({
message: NonEmptyString,
model: Type.String(),
+ fallbacks: Type.Array(Type.String()),
toolsAllow: Type.Array(Type.String()),
}),
cronCommandPayloadSchema({
@@ -251,6 +253,7 @@ export const CronPayloadPatchSchema = Type.Union([
cronAgentTurnPayloadSchema({
message: Type.Optional(NonEmptyString),
model: Type.Union([Type.String(), Type.Null()]),
+ fallbacks: Type.Union([Type.Array(Type.String()), Type.Null()]),
toolsAllow: Type.Union([Type.Array(Type.String()), Type.Null()]),
}),
cronCommandPayloadSchema({
diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts
index 6a48b3f45e2a..5547dfe146fa 100644
--- a/src/cli/cron-cli.test.ts
+++ b/src/cli/cron-cli.test.ts
@@ -68,6 +68,7 @@ type CronUpdatePatch = {
input?: string;
message?: string;
model?: string;
+ fallbacks?: string[] | null;
thinking?: string;
lightContext?: boolean;
timeoutSeconds?: number;
@@ -97,6 +98,7 @@ type CronAddParams = {
input?: string;
message?: string;
model?: string;
+ fallbacks?: string[];
thinking?: string;
lightContext?: boolean;
timeoutSeconds?: number;
@@ -1044,6 +1046,23 @@ describe("cron cli", () => {
expect(params?.payload?.toolsAllow).toEqual(["exec", "read", "write"]);
});
+ it("sets fallback models on cron add", async () => {
+ const params = await runCronAddAndGetParams([
+ "--name",
+ "Fallbacks",
+ "--cron",
+ "* * * * *",
+ "--session",
+ "isolated",
+ "--message",
+ "hello",
+ "--fallbacks",
+ "openrouter/gpt-4.1-mini openai/gpt-5",
+ ]);
+
+ expect(params?.payload?.fallbacks).toEqual(["openrouter/gpt-4.1-mini", "openai/gpt-5"]);
+ });
+
it.each([
{
label: "omits empty model and thinking",
@@ -1074,6 +1093,27 @@ describe("cron cli", () => {
expect(patch?.patch?.payload?.toolsAllow).toEqual(["exec", "read", "write"]);
});
+ it("sets fallback models on cron edit", async () => {
+ const patch = await runCronEditAndGetPatch([
+ "--fallbacks",
+ "openrouter/gpt-4.1-mini,openai/gpt-5",
+ ]);
+
+ expect(patch?.patch?.payload?.fallbacks).toEqual(["openrouter/gpt-4.1-mini", "openai/gpt-5"]);
+ });
+
+ it("sets strict empty fallbacks on cron edit", async () => {
+ const patch = await runCronEditAndGetPatch(["--fallbacks", ""]);
+
+ expect(patch?.patch?.payload?.fallbacks).toEqual([]);
+ });
+
+ it("clears fallback models on cron edit", async () => {
+ const patch = await runCronEditAndGetPatch(["--clear-fallbacks"]);
+
+ expect(patch?.patch?.payload?.fallbacks).toBeNull();
+ });
+
it("sets and clears agent id on cron edit", async () => {
await runCronCommand(["cron", "edit", "job-1", "--agent", " Ops ", "--message", "hello"]);
diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts
index c085a0c821ce..70dd8aade381 100644
--- a/src/cli/cron-cli/register.cron-add.ts
+++ b/src/cli/cron-cli/register.cron-add.ts
@@ -22,6 +22,7 @@ import {
handleCronCliError,
parseCronCommandArgv,
parseCronCommandEnv,
+ parseCronFallbacks,
parseCronToolsAllow,
printCronJson,
printCronList,
@@ -124,6 +125,7 @@ export function registerCronAddCommand(cron: Command) {
"Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)",
)
.option("--model ", "Model override for agent jobs (provider/model or alias)")
+ .option("--fallbacks ", "Fallback model list for agent jobs")
.option("--timeout-seconds ", "Timeout seconds for agent or command jobs")
.option("--no-output-timeout-seconds ", "No-output timeout seconds for command jobs")
.option("--output-max-bytes ", "Maximum captured stdout/stderr bytes for command jobs")
@@ -254,6 +256,7 @@ export function registerCronAddCommand(cron: Command) {
kind: "agentTurn" as const,
message,
model: normalizeOptionalString(opts.model),
+ fallbacks: parseCronFallbacks(opts.fallbacks),
thinking: normalizeOptionalString(opts.thinking),
timeoutSeconds:
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts
index 2e6f832619c3..2e7a34592c41 100644
--- a/src/cli/cron-cli/register.cron-edit.ts
+++ b/src/cli/cron-cli/register.cron-edit.ts
@@ -18,6 +18,7 @@ import {
getCronChannelOptions,
parseCronCommandArgv,
parseCronCommandEnv,
+ parseCronFallbacks,
parseCronToolsAllow,
parseDurationMs,
warnIfCronSchedulerDisabled,
@@ -112,6 +113,8 @@ export function registerCronEditCommand(cron: Command) {
"Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)",
)
.option("--model ", "Model override for agent jobs")
+ .option("--fallbacks ", "Fallback model list for agent jobs")
+ .option("--clear-fallbacks", "Remove per-job fallback override", false)
.option(
"--clear-model",
"Remove the per-job model override (restore normal cron model precedence)",
@@ -289,6 +292,10 @@ export function registerCronEditCommand(cron: Command) {
throw new Error("Use --model or --clear-model, not both");
}
const thinking = normalizeOptionalString(opts.thinking);
+ const fallbacks = parseCronFallbacks(opts.fallbacks);
+ if (typeof opts.fallbacks === "string" && opts.clearFallbacks) {
+ throw new Error("Use --fallbacks or --clear-fallbacks, not both");
+ }
const toolsAllow = parseCronToolsAllow(opts.tools);
const rawTimeoutSeconds =
opts.timeoutSeconds === undefined ? undefined : String(opts.timeoutSeconds).trim();
@@ -360,6 +367,8 @@ export function registerCronEditCommand(cron: Command) {
!hasCommandSpecificPayloadField &&
typeof opts.message !== "string" &&
!model &&
+ typeof opts.fallbacks !== "string" &&
+ !opts.clearFallbacks &&
!thinking &&
typeof opts.lightContext !== "boolean" &&
typeof opts.tools !== "string" &&
@@ -373,6 +382,8 @@ export function registerCronEditCommand(cron: Command) {
typeof opts.message === "string" ||
Boolean(model) ||
Boolean(opts.clearModel) ||
+ typeof opts.fallbacks === "string" ||
+ Boolean(opts.clearFallbacks) ||
Boolean(thinking) ||
(hasTimeoutSeconds &&
!hasCommandSpecificPayloadField &&
@@ -405,6 +416,8 @@ export function registerCronEditCommand(cron: Command) {
} else {
assignIf(payload, "model", model, Boolean(model));
}
+ assignIf(payload, "fallbacks", fallbacks, typeof opts.fallbacks === "string");
+ assignIf(payload, "fallbacks", null, Boolean(opts.clearFallbacks));
assignIf(payload, "thinking", thinking, Boolean(thinking));
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
assignIf(
diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts
index 156045cf6670..8f784d3f7d9c 100644
--- a/src/cli/cron-cli/shared.ts
+++ b/src/cli/cron-cli/shared.ts
@@ -265,6 +265,21 @@ export function parseCronToolsAllow(input: unknown): string[] | undefined {
return tools.length > 0 ? tools : undefined;
}
+export function parseCronFallbacks(input: unknown): string[] | undefined {
+ if (input === undefined) {
+ return undefined;
+ }
+ const raw = Array.isArray(input)
+ ? input.map((value) => String(value)).join(" ")
+ : typeof input === "string"
+ ? input
+ : "";
+ return raw
+ .split(/[,\s]+/u)
+ .map((fallback) => normalizeOptionalString(fallback))
+ .filter((fallback): fallback is string => Boolean(fallback));
+}
+
/**
* Parse a one-shot `--at` value into an ISO string (UTC).
*
diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts
index 9855d6e3d2db..d7cabd3233ef 100644
--- a/src/cron/normalize.test.ts
+++ b/src/cron/normalize.test.ts
@@ -876,6 +876,20 @@ describe("normalizeCronJobPatch", () => {
expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
});
+ it("preserves null fallback lists so patches can clear the fallback override", () => {
+ const normalized = normalizeCronJobPatch({
+ payload: {
+ kind: "agentTurn",
+ fallbacks: null,
+ },
+ }) as unknown as Record;
+
+ const payload = normalized.payload as Record;
+ expect(payload.kind).toBe("agentTurn");
+ expect(payload.fallbacks).toBeNull();
+ expect(validateCronUpdateParams({ id: "job-1", patch: normalized })).toBe(true);
+ });
+
it("promotes implicit text payloads with agentTurn hints to agentTurn patches", () => {
const normalized = normalizeCronJobPatch({
payload: {
diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts
index 7ddce5409367..98ab1a72f107 100644
--- a/src/cron/normalize.ts
+++ b/src/cron/normalize.ts
@@ -210,7 +210,7 @@ function coercePayload(payload: UnknownRecord) {
}
}
if ("fallbacks" in next) {
- const fallbacks = normalizeTrimmedStringArray(next.fallbacks);
+ const fallbacks = normalizeTrimmedStringArray(next.fallbacks, { allowNull: true });
if (fallbacks !== undefined) {
next.fallbacks = fallbacks;
} else {
diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts
index 671a7f4fe2e2..3834ef59d648 100644
--- a/src/cron/service.jobs.test.ts
+++ b/src/cron/service.jobs.test.ts
@@ -320,6 +320,53 @@ describe("applyJobPatch", () => {
}
});
+ it("clears agentTurn payload.fallbacks when patch requests null", () => {
+ const job = createIsolatedAgentTurnJob("job-fallbacks-clear", {
+ mode: "announce",
+ channel: "telegram",
+ });
+ job.payload = {
+ kind: "agentTurn",
+ message: "do it",
+ fallbacks: ["openrouter/gpt-4.1-mini"],
+ };
+
+ applyJobPatch(job, {
+ payload: {
+ kind: "agentTurn",
+ message: "do it",
+ fallbacks: null,
+ },
+ });
+
+ expect(job.payload.kind).toBe("agentTurn");
+ if (job.payload.kind === "agentTurn") {
+ expect(job.payload.fallbacks).toBeUndefined();
+ }
+ });
+
+ it("omits null payload.fallbacks when replacing a non-agent payload", () => {
+ const job = createIsolatedAgentTurnJob("job-fallbacks-kind-switch", {
+ mode: "announce",
+ channel: "telegram",
+ });
+ job.payload = { kind: "systemEvent", text: "tick" };
+
+ applyJobPatch(job, {
+ payload: {
+ kind: "agentTurn",
+ message: "do it",
+ fallbacks: null,
+ },
+ });
+
+ const payload = job.payload as CronJob["payload"];
+ expect(payload.kind).toBe("agentTurn");
+ if (payload.kind === "agentTurn") {
+ expect(payload.fallbacks).toBeUndefined();
+ }
+ });
+
it("persists agentTurn payload.toolsAllow updates when editing existing jobs", () => {
const job = createIsolatedAgentTurnJob("job-tools", {
mode: "announce",
diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts
index 835ad7eb011c..39343809098f 100644
--- a/src/cron/service/jobs.ts
+++ b/src/cron/service/jobs.ts
@@ -954,6 +954,8 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP
}
if (Array.isArray(patch.fallbacks)) {
next.fallbacks = patch.fallbacks;
+ } else if (patch.fallbacks === null) {
+ delete next.fallbacks;
}
if (Array.isArray(patch.toolsAllow)) {
next.toolsAllow = patch.toolsAllow;
@@ -1007,7 +1009,7 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
kind: "agentTurn",
message: patch.message,
model: typeof patch.model === "string" ? patch.model : undefined,
- fallbacks: patch.fallbacks,
+ fallbacks: Array.isArray(patch.fallbacks) ? patch.fallbacks : undefined,
toolsAllow: Array.isArray(patch.toolsAllow) ? patch.toolsAllow : undefined,
thinking: patch.thinking,
timeoutSeconds: patch.timeoutSeconds,
diff --git a/src/cron/types.ts b/src/cron/types.ts
index 6ecc80d582db..efc1114256ff 100644
--- a/src/cron/types.ts
+++ b/src/cron/types.ts
@@ -256,8 +256,9 @@ type CronAgentTurnPayload = {
type CronAgentTurnPayloadPatch = {
kind: "agentTurn";
-} & Partial> & {
+} & Partial> & {
model?: string | null;
+ fallbacks?: string[] | null;
toolsAllow?: string[] | null;
};