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; };