Skip to content

Commit 7a381b8

Browse files
fix(cron): preflight model fallbacks before skip (#82887)
Fix cron local-model preflight fallback handling so scheduled runs try configured fallback candidates before skipping when the local primary is unavailable. Verification: - GitHub CI on PR head fe884da: passing required CI checks. - Local focused cron/model fallback tests passed earlier for the touched surface. - Local merge-wrapper build and check passed on the prepared candidate. - Local full pnpm test reported unrelated failures outside this PR's touched files; touched files are limited to cron docs, src/agents/model-fallback.ts, and src/cron/isolated-agent/*. Co-authored-by: chen-zhang-cs-code <[email protected]> Co-authored-by: Onur Solmaz <[email protected]>
1 parent c559776 commit 7a381b8

8 files changed

Lines changed: 293 additions & 19 deletions

File tree

docs/automation/cron-jobs.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ This fires ~5–6 times per month instead of 0–1 times per month. OpenClaw use
145145

146146
Cron jobs can also carry payload-level `fallbacks`. When present, that list replaces the configured fallback chain for the job. Use `fallbacks: []` in the job payload/API when you want a strict cron run that tries only the selected model. If a job has `--model` but neither payload nor configured fallbacks, OpenClaw passes an explicit empty fallback override so the agent primary is not appended as a hidden extra retry target.
147147

148+
Local-provider preflight checks walk configured fallbacks before marking a cron run `skipped`; `fallbacks: []` keeps that preflight path strict.
149+
148150
Model-selection precedence for isolated jobs is:
149151

150152
1. Gmail hook model override (when the run came from Gmail and that override is allowed)

docs/cli/cron.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ Cron `--model` is a **job primary**, not a chat-session `/model` override. That
155155
- Per-job payload `fallbacks` replaces the configured fallback list when present.
156156
- An empty per-job fallback list (`fallbacks: []` in the job payload/API) makes the cron run strict.
157157
- 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.
158+
- Local-provider preflight checks walk configured fallbacks before marking a cron run `skipped`.
158159

159160
`openclaw doctor` reports jobs that already have `payload.model` set, including provider namespace counts and mismatches against `agents.defaults.model`. Use that check when auth, provider, or billing behavior looks different between live chat and scheduled jobs.
160161

src/agents/model-fallback.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -690,13 +690,13 @@ export function resolveImageFallbackDefaultProvider(cfg: OpenClawConfig | undefi
690690
}
691691

692692
export const testing = {
693-
resolveFallbackCandidates,
693+
resolveFallbackCandidates: resolveModelCandidateChain,
694694
resolveImageFallbackCandidates,
695695
resolveCooldownDecision,
696696
resolveSessionSuspensionReason,
697697
} as const;
698698

699-
function resolveFallbackCandidates(
699+
export function resolveModelCandidateChain(
700700
params: {
701701
cfg: OpenClawConfig | undefined;
702702
provider: string;
@@ -1142,7 +1142,7 @@ export async function runWithModelFallback<T>(
11421142
abortSignal?: AbortSignal;
11431143
} & ModelManifestNormalizationContext,
11441144
): Promise<ModelFallbackRunResult<T>> {
1145-
const candidates = resolveFallbackCandidates({
1145+
const candidates = resolveModelCandidateChain({
11461146
cfg: params.cfg,
11471147
provider: params.provider,
11481148
model: params.model,

src/cron/isolated-agent.model-preflight.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { beforeEach, describe, expect, it } from "vitest";
22
import {
33
loadRunCronIsolatedAgentTurn,
4+
logWarnMock,
45
makeCronSession,
6+
mockRunCronFallbackPassthrough,
57
preflightCronModelProviderMock,
68
resolveConfiguredModelRefMock,
79
resolveCronSessionMock,
810
resetRunCronIsolatedAgentTurnHarness,
911
runEmbeddedAgentMock,
12+
runWithModelFallbackMock,
1013
} from "./isolated-agent/run.test-harness.js";
1114

1215
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
@@ -43,6 +46,14 @@ describe("runCronIsolatedAgentTurn model provider preflight", () => {
4346

4447
const result = await runCronIsolatedAgentTurn({
4548
cfg: {
49+
agents: {
50+
defaults: {
51+
model: {
52+
primary: "ollama/qwen3:32b",
53+
fallbacks: [],
54+
},
55+
},
56+
},
4657
models: {
4758
providers: {
4859
ollama: {
@@ -79,4 +90,135 @@ describe("runCronIsolatedAgentTurn model provider preflight", () => {
7990
expect(result.error).toContain("local provider endpoint is not reachable");
8091
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
8192
});
93+
94+
it("continues with configured fallback when the local primary preflight is unavailable", async () => {
95+
mockRunCronFallbackPassthrough();
96+
preflightCronModelProviderMock.mockResolvedValueOnce({
97+
status: "unavailable",
98+
reason:
99+
"Agent cron job uses ollama/qwen3:32b but the local provider endpoint is not reachable at http://127.0.0.1:11434.",
100+
provider: "ollama",
101+
model: "qwen3:32b",
102+
baseUrl: "http://127.0.0.1:11434",
103+
retryAfterMs: 300000,
104+
});
105+
106+
const result = await runCronIsolatedAgentTurn({
107+
cfg: {
108+
agents: {
109+
defaults: {
110+
model: {
111+
primary: "ollama/qwen3:32b",
112+
fallbacks: ["openrouter/nvidia/nemotron-3-super-120b-a12b:free", "openai/gpt-5.4"],
113+
},
114+
},
115+
},
116+
models: {
117+
providers: {
118+
ollama: {
119+
api: "ollama",
120+
baseUrl: "http://127.0.0.1:11434",
121+
models: [],
122+
},
123+
openrouter: {
124+
api: "openai-completions",
125+
baseUrl: "https://openrouter.ai/api/v1",
126+
models: [],
127+
},
128+
},
129+
},
130+
},
131+
deps: {} as never,
132+
job: {
133+
id: "fallback-from-dead-ollama",
134+
name: "Fallback From Dead Ollama",
135+
enabled: true,
136+
createdAtMs: 0,
137+
updatedAtMs: 0,
138+
schedule: { kind: "cron", expr: "*/5 * * * *", tz: "UTC" },
139+
sessionTarget: "isolated",
140+
state: {},
141+
wakeMode: "next-heartbeat",
142+
payload: { kind: "agentTurn", message: "summarize" },
143+
delivery: { mode: "none" },
144+
},
145+
message: "summarize",
146+
sessionKey: "cron:fallback-from-dead-ollama",
147+
lane: "cron",
148+
});
149+
150+
expect(result.status).toBe("ok");
151+
expect(result.provider).toBe("openrouter");
152+
expect(result.model).toBe("nvidia/nemotron-3-super-120b-a12b:free");
153+
expect(preflightCronModelProviderMock.mock.calls.map((call) => call[0])).toMatchObject([
154+
{ provider: "ollama", model: "qwen3:32b" },
155+
{ provider: "openrouter", model: "nvidia/nemotron-3-super-120b-a12b:free" },
156+
]);
157+
expect(runEmbeddedAgentMock.mock.calls[0]?.[0]).toMatchObject({
158+
provider: "openrouter",
159+
model: "nvidia/nemotron-3-super-120b-a12b:free",
160+
});
161+
expect(runWithModelFallbackMock.mock.calls[0]?.[0]).toMatchObject({
162+
fallbacksOverride: ["openai/gpt-5.4"],
163+
});
164+
expect(String(logWarnMock.mock.calls[0]?.[0] ?? "")).toContain(
165+
"continuing with fallback openrouter/nvidia/nemotron-3-super-120b-a12b:free",
166+
);
167+
expect(String(logWarnMock.mock.calls[0]?.[0] ?? "")).not.toContain("Skipping this cron run");
168+
});
169+
170+
it("keeps explicit empty payload fallbacks strict when local primary preflight fails", async () => {
171+
preflightCronModelProviderMock.mockResolvedValueOnce({
172+
status: "unavailable",
173+
reason:
174+
"Agent cron job uses ollama/qwen3:32b but the local provider endpoint is not reachable at http://127.0.0.1:11434.",
175+
provider: "ollama",
176+
model: "qwen3:32b",
177+
baseUrl: "http://127.0.0.1:11434",
178+
retryAfterMs: 300000,
179+
});
180+
181+
const result = await runCronIsolatedAgentTurn({
182+
cfg: {
183+
agents: {
184+
defaults: {
185+
model: {
186+
primary: "ollama/qwen3:32b",
187+
fallbacks: ["openrouter/nvidia/nemotron-3-super-120b-a12b:free"],
188+
},
189+
},
190+
},
191+
models: {
192+
providers: {
193+
ollama: {
194+
api: "ollama",
195+
baseUrl: "http://127.0.0.1:11434",
196+
models: [],
197+
},
198+
},
199+
},
200+
},
201+
deps: {} as never,
202+
job: {
203+
id: "strict-dead-ollama",
204+
name: "Strict Dead Ollama",
205+
enabled: true,
206+
createdAtMs: 0,
207+
updatedAtMs: 0,
208+
schedule: { kind: "cron", expr: "*/5 * * * *", tz: "UTC" },
209+
sessionTarget: "isolated",
210+
state: {},
211+
wakeMode: "next-heartbeat",
212+
payload: { kind: "agentTurn", message: "summarize", fallbacks: [] },
213+
delivery: { mode: "none" },
214+
},
215+
message: "summarize",
216+
sessionKey: "cron:strict-dead-ollama",
217+
lane: "cron",
218+
});
219+
220+
expect(result.status).toBe("skipped");
221+
expect(preflightCronModelProviderMock).toHaveBeenCalledOnce();
222+
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
223+
});
82224
});

src/cron/isolated-agent/run-executor.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export function createCronPromptExecutor(params: {
139139
skillsSnapshot: SkillSnapshot;
140140
agentPayload: AgentTurnPayload;
141141
useSubagentFallbacks: boolean;
142+
modelFallbacksOverride?: string[];
142143
liveSelection: CronLiveSelection;
143144
cronSession: MutableCronSession;
144145
abortSignal?: AbortSignal;
@@ -156,12 +157,14 @@ export function createCronPromptExecutor(params: {
156157
if (!params.cronSession.sessionEntry.sessionFile?.trim()) {
157158
params.cronSession.sessionEntry.sessionFile = sessionFile;
158159
}
159-
const cronFallbacksOverride = resolveCronFallbacksOverride({
160-
cfg: params.cfg,
161-
job: params.job,
162-
agentId: params.agentId,
163-
useSubagentFallbacks: params.useSubagentFallbacks,
164-
});
160+
const cronFallbacksOverride =
161+
params.modelFallbacksOverride ??
162+
resolveCronFallbacksOverride({
163+
cfg: params.cfg,
164+
job: params.job,
165+
agentId: params.agentId,
166+
useSubagentFallbacks: params.useSubagentFallbacks,
167+
});
165168
let runResult: CronPromptRunResult | undefined;
166169
let fallbackProvider = params.liveSelection.provider;
167170
let fallbackModel = params.liveSelection.model;
@@ -366,6 +369,7 @@ export async function executeCronRun(params: {
366369
skillsSnapshot: SkillSnapshot;
367370
agentPayload: AgentTurnPayload;
368371
useSubagentFallbacks: boolean;
372+
modelFallbacksOverride?: string[];
369373
agentVerboseDefault: AgentDefaultsConfig["verboseDefault"];
370374
liveSelection: CronLiveSelection;
371375
cronSession: MutableCronSession;
@@ -414,6 +418,7 @@ export async function executeCronRun(params: {
414418
skillsSnapshot: params.skillsSnapshot,
415419
agentPayload: params.agentPayload,
416420
useSubagentFallbacks: params.useSubagentFallbacks,
421+
modelFallbacksOverride: params.modelFallbacksOverride,
417422
liveSelection: params.liveSelection,
418423
cronSession: params.cronSession,
419424
abortSignal: params.abortSignal,

src/cron/isolated-agent/run-fallback-policy.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import { readFileSync } from "node:fs";
12
import { describe, expect, it } from "vitest";
23
import type { OpenClawConfig } from "../../config/types.openclaw.js";
34
import type { CronJob } from "../types.js";
4-
import { resolveCronFallbacksOverride } from "./run-fallback-policy.js";
5+
import {
6+
resolveCronFallbacksOverride,
7+
resolveCronPreflightCandidates,
8+
} from "./run-fallback-policy.js";
59

610
function makeJob(payload: CronJob["payload"]): CronJob {
711
return {
@@ -257,4 +261,56 @@ describe("resolveCronFallbacksOverride", () => {
257261
}),
258262
).toBeUndefined();
259263
});
264+
265+
it("plans the full configured candidate chain for cron preflight", () => {
266+
expect(
267+
resolveCronPreflightCandidates({
268+
cfg: {
269+
agents: {
270+
defaults: {
271+
model: {
272+
primary: "ollama/qwen3:32b",
273+
fallbacks: ["openrouter/nvidia/nemotron-3-super-120b-a12b:free", "openai/gpt-5.4"],
274+
},
275+
},
276+
},
277+
},
278+
agentId: "main",
279+
provider: "ollama",
280+
model: "qwen3:32b",
281+
job: makeJob({
282+
kind: "agentTurn",
283+
message: "summarize",
284+
}),
285+
}),
286+
).toEqual([
287+
{ provider: "ollama", model: "qwen3:32b" },
288+
{ provider: "openrouter", model: "nvidia/nemotron-3-super-120b-a12b:free" },
289+
{ provider: "openai", model: "gpt-5.4" },
290+
]);
291+
});
292+
293+
it("keeps cron preflight strict when payload fallbacks are explicitly empty", () => {
294+
expect(
295+
resolveCronPreflightCandidates({
296+
cfg: makeConfig(["openai/gpt-5.4"]),
297+
agentId: "main",
298+
provider: "ollama",
299+
model: "qwen3:32b",
300+
job: makeJob({
301+
kind: "agentTurn",
302+
message: "summarize",
303+
fallbacks: [],
304+
}),
305+
}),
306+
).toStrictEqual([{ provider: "ollama", model: "qwen3:32b" }]);
307+
});
308+
309+
it("documents that cron preflight walks fallbacks before skipping", () => {
310+
const cliDocs = readFileSync("docs/cli/cron.md", "utf8");
311+
const automationDocs = readFileSync("docs/automation/cron-jobs.md", "utf8");
312+
313+
expect(cliDocs).toContain("Local-provider preflight checks walk configured fallbacks");
314+
expect(automationDocs).toContain("Local-provider preflight checks walk configured fallbacks");
315+
});
260316
});

src/cron/isolated-agent/run-fallback-policy.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { resolveModelCandidateChain } from "../../agents/model-fallback.js";
2+
import type { ModelCandidate } from "../../agents/model-fallback.types.js";
13
import type { OpenClawConfig } from "../../config/types.openclaw.js";
24
import type { CronJob } from "../types.js";
35
import {
@@ -34,3 +36,25 @@ export function resolveCronFallbacksOverride(params: {
3436
modelOverrideSource: hasCronPayloadModelOverride ? "auto" : undefined,
3537
});
3638
}
39+
40+
export function resolveCronPreflightCandidates(params: {
41+
cfg: OpenClawConfig;
42+
job: CronJob;
43+
agentId: string;
44+
provider: string;
45+
model: string;
46+
useSubagentFallbacks?: boolean;
47+
}): ModelCandidate[] {
48+
const fallbacksOverride = resolveCronFallbacksOverride({
49+
cfg: params.cfg,
50+
job: params.job,
51+
agentId: params.agentId,
52+
useSubagentFallbacks: params.useSubagentFallbacks,
53+
});
54+
return resolveModelCandidateChain({
55+
cfg: params.cfg,
56+
provider: params.provider,
57+
model: params.model,
58+
fallbacksOverride,
59+
});
60+
}

0 commit comments

Comments
 (0)