fix(cron): expose per-job fallbacks in CLI (#93369)

This commit is contained in:
Yzx
2026-06-23 03:22:20 +08:00
committed by GitHub
parent cf31689a03
commit 1662b07810
12 changed files with 151 additions and 7 deletions
+7 -1
View File
@@ -157,6 +157,12 @@ If stdout is non-empty, that text is the delivered result. If stdout is empty an
<ParamField path="--model" type="string">
Model override; uses the selected allowed model for the job.
</ParamField>
<ParamField path="--fallbacks" type="string">
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.
</ParamField>
<ParamField path="--clear-fallbacks" type="boolean">
On `cron edit`, removes the per-job fallback override so the job follows configured fallback precedence. Cannot be combined with `--fallbacks`.
</ParamField>
<ParamField path="--clear-model" type="boolean">
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`.
</ParamField>
@@ -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 <job-id> --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 <job-id> --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.
</Note>
+2 -2
View File
@@ -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 <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`.
`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`.
<Warning>
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`.
+4 -1
View File
@@ -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({
+40
View File
@@ -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"]);
+3
View File
@@ -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>", "Model override for agent jobs (provider/model or alias)")
.option("--fallbacks <list>", "Fallback model list for agent jobs")
.option("--timeout-seconds <n>", "Timeout seconds for agent or command jobs")
.option("--no-output-timeout-seconds <n>", "No-output timeout seconds for command jobs")
.option("--output-max-bytes <n>", "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,
+13
View File
@@ -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>", "Model override for agent jobs")
.option("--fallbacks <list>", "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(
+15
View File
@@ -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).
*
+14
View File
@@ -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<string, unknown>;
const payload = normalized.payload as Record<string, unknown>;
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: {
+1 -1
View File
@@ -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 {
+47
View File
@@ -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",
+3 -1
View File
@@ -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,
+2 -1
View File
@@ -256,8 +256,9 @@ type CronAgentTurnPayload = {
type CronAgentTurnPayloadPatch = {
kind: "agentTurn";
} & Partial<Omit<CronAgentTurnPayloadFields, "model" | "toolsAllow">> & {
} & Partial<Omit<CronAgentTurnPayloadFields, "model" | "fallbacks" | "toolsAllow">> & {
model?: string | null;
fallbacks?: string[] | null;
toolsAllow?: string[] | null;
};