mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(cron): expose per-job fallbacks in CLI (#93369)
This commit is contained in:
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user