Skip to content

Commit 5d14b9b

Browse files
fix(cron): thread raw-config default-primary shorthand signal through cron fallback resolution
Compute the isDefaultPrimaryShorthand decision from raw runtime config before buildCronAgentDefaultsConfig rewrites defaults, and pass the explicit boolean into both preflight (resolveCronPreflightCandidates) and execution (resolveCronFallbacksOverride via executeCronRun). This prevents differing string agent models from accidentally inheriting global default fallbacks after defaults are rewritten in the cron path. - run.ts: compute signal before cfgWithAgentDefaults, thread through context - run-executor.ts: forward signal into resolveCronFallbacksOverride - run-fallback-policy.ts: use explicit signal instead of comparing rewritten configs - run-fallback-policy.test.ts: add rewritten-defaults strict regression test Refs: ClawSweeper P1 finding on PR #91373 Co-Authored-By: Claude Opus 4.7 <[email protected]>
1 parent 439dcbd commit 5d14b9b

5 files changed

Lines changed: 219 additions & 1 deletion

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Live repro for PR #91373: cron sessions with string agent model configs
4+
* should inherit agents.defaults.model.fallbacks.
5+
*
6+
* Run: pnpm exec tsx scripts/repro/91373-cron-fallback-proof.mjs
7+
*/
8+
import {
9+
resolveCronPreflightCandidates,
10+
resolveCronFallbacksOverride,
11+
} from "../../src/cron/isolated-agent/run-fallback-policy.ts";
12+
13+
function makeJob(payload) {
14+
return {
15+
id: "91373-proof",
16+
name: "Cron fallback inheritance proof",
17+
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
18+
sessionTarget: "isolated",
19+
payload,
20+
state: {},
21+
};
22+
}
23+
24+
const cfg = {
25+
agents: {
26+
defaults: {
27+
model: {
28+
primary: "openai/gpt-5.4",
29+
fallbacks: ["anthropic/claude-sonnet-4-6", "google/gemini-3-pro"],
30+
},
31+
},
32+
list: [
33+
{
34+
id: "main",
35+
// This is the bug-triggering shape: a plain string model config
36+
// instead of an object with explicit fallbacks.
37+
model: "openai/gpt-5.4",
38+
},
39+
],
40+
},
41+
};
42+
43+
console.log("=== PR #91373 Cron fallback inheritance proof ===\n");
44+
45+
console.log("Config shape:");
46+
console.log(
47+
' agents.defaults.model.fallbacks: ["anthropic/claude-sonnet-4-6", "google/gemini-3-pro"]',
48+
);
49+
console.log(' agents.list[0].model: "openai/gpt-5.4" (string, no explicit fallbacks)\n');
50+
51+
const fallbacksOverride = resolveCronFallbacksOverride({
52+
cfg,
53+
agentId: "main",
54+
job: makeJob({ kind: "agentTurn", message: "summarize" }),
55+
});
56+
57+
console.log(
58+
"resolveCronFallbacksOverride result:",
59+
fallbacksOverride === undefined
60+
? "undefined (will fall through to defaults)"
61+
: JSON.stringify(fallbacksOverride),
62+
);
63+
64+
const candidates = resolveCronPreflightCandidates({
65+
cfg,
66+
agentId: "main",
67+
provider: "openai",
68+
model: "gpt-5.4",
69+
job: makeJob({ kind: "agentTurn", message: "summarize" }),
70+
});
71+
72+
console.log("\nresolveCronPreflightCandidates result:");
73+
for (const candidate of candidates) {
74+
console.log(` - ${candidate.provider}/${candidate.model}`);
75+
}
76+
77+
// The exact resolved model may vary due to alias normalization; the
78+
// critical behavior is that the candidate chain walks the configured
79+
// default fallbacks instead of stopping at the primary.
80+
const hasPrimary = candidates.some((c) => c.provider === "openai" && c.model === "gpt-5.4");
81+
const hasAnthropicFallback = candidates.some((c) => c.provider === "anthropic");
82+
const hasGoogleFallback = candidates.some((c) => c.provider === "google");
83+
const matches = hasPrimary && hasAnthropicFallback && hasGoogleFallback && candidates.length >= 3;
84+
85+
console.log("\nVerification:");
86+
console.log(` Primary present: ${hasPrimary}`);
87+
console.log(` Anthropic fallback present: ${hasAnthropicFallback}`);
88+
console.log(` Google fallback present: ${hasGoogleFallback}`);
89+
console.log(` Total candidates: ${candidates.length}`);
90+
console.log(
91+
matches ? "\nPASS: fallback chain inherits defaults." : "\nFAIL: fallback chain mismatch.",
92+
);
93+
process.exit(matches ? 0 : 1);

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export function createCronPromptExecutor(params: {
146146
agentPayload: AgentTurnPayload;
147147
useSubagentFallbacks: boolean;
148148
modelFallbacksOverride?: string[];
149+
isDefaultPrimaryShorthand?: boolean;
149150
liveSelection: CronLiveSelection;
150151
cronSession: MutableCronSession;
151152
abortSignal?: AbortSignal;
@@ -170,6 +171,7 @@ export function createCronPromptExecutor(params: {
170171
job: params.job,
171172
agentId: params.agentId,
172173
useSubagentFallbacks: params.useSubagentFallbacks,
174+
isDefaultPrimaryShorthand: params.isDefaultPrimaryShorthand,
173175
});
174176
let runResult: CronPromptRunResult | undefined;
175177
let fallbackProvider = params.liveSelection.provider;
@@ -381,6 +383,7 @@ export async function executeCronRun(params: {
381383
agentPayload: AgentTurnPayload;
382384
useSubagentFallbacks: boolean;
383385
modelFallbacksOverride?: string[];
386+
isDefaultPrimaryShorthand?: boolean;
384387
agentVerboseDefault: AgentDefaultsConfig["verboseDefault"];
385388
liveSelection: CronLiveSelection;
386389
cronSession: MutableCronSession;
@@ -431,6 +434,7 @@ export async function executeCronRun(params: {
431434
agentPayload: params.agentPayload,
432435
useSubagentFallbacks: params.useSubagentFallbacks,
433436
modelFallbacksOverride: params.modelFallbacksOverride,
437+
isDefaultPrimaryShorthand: params.isDefaultPrimaryShorthand,
434438
liveSelection: params.liveSelection,
435439
cronSession: params.cronSession,
436440
abortSignal: params.abortSignal,

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

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,105 @@ describe("resolveCronFallbacksOverride", () => {
307307
).toStrictEqual([{ provider: "ollama", model: "qwen3:32b" }]);
308308
});
309309

310+
it("inherits default fallbacks for string agent model configs during cron preflight", () => {
311+
expect(
312+
resolveCronPreflightCandidates({
313+
cfg: {
314+
agents: {
315+
defaults: {
316+
model: {
317+
primary: "openai/gpt-5.4",
318+
fallbacks: ["anthropic/claude-sonnet-4-6"],
319+
},
320+
},
321+
list: [
322+
{
323+
id: "main",
324+
model: "openai/gpt-5.4",
325+
},
326+
],
327+
},
328+
},
329+
agentId: "main",
330+
provider: "openai",
331+
model: "gpt-5.4",
332+
isDefaultPrimaryShorthand: true,
333+
job: makeJob({
334+
kind: "agentTurn",
335+
message: "summarize",
336+
}),
337+
}),
338+
).toEqual([
339+
{ provider: "openai", model: "gpt-5.4" },
340+
{ provider: "anthropic", model: "claude-sonnet-4-6" },
341+
]);
342+
});
343+
344+
it("keeps differing string agent models strict in cron preflight", () => {
345+
expect(
346+
resolveCronPreflightCandidates({
347+
cfg: {
348+
agents: {
349+
defaults: {
350+
model: {
351+
primary: "openai/gpt-5.4",
352+
fallbacks: ["anthropic/claude-sonnet-4-6"],
353+
},
354+
},
355+
list: [
356+
{
357+
id: "main",
358+
model: "google/gemini-3-pro",
359+
},
360+
],
361+
},
362+
},
363+
agentId: "main",
364+
provider: "google",
365+
model: "gemini-3-pro",
366+
isDefaultPrimaryShorthand: false,
367+
job: makeJob({
368+
kind: "agentTurn",
369+
message: "summarize",
370+
}),
371+
}),
372+
).toEqual([{ provider: "google", model: expect.stringContaining("gemini-3") }]);
373+
});
374+
375+
it("keeps rewritten defaults strict when the signal says the agent is not a shorthand", () => {
376+
// This simulates the real cron preflight path where buildCronAgentDefaultsConfig
377+
// has already copied the per-agent string model into agents.defaults.model.primary,
378+
// making the config look like a shorthand even when the raw config was different.
379+
expect(
380+
resolveCronPreflightCandidates({
381+
cfg: {
382+
agents: {
383+
defaults: {
384+
model: {
385+
primary: "google/gemini-3-pro",
386+
fallbacks: ["anthropic/claude-sonnet-4-6"],
387+
},
388+
},
389+
list: [
390+
{
391+
id: "main",
392+
model: "google/gemini-3-pro",
393+
},
394+
],
395+
},
396+
},
397+
agentId: "main",
398+
provider: "google",
399+
model: "gemini-3-pro",
400+
isDefaultPrimaryShorthand: false,
401+
job: makeJob({
402+
kind: "agentTurn",
403+
message: "summarize",
404+
}),
405+
}),
406+
).toEqual([{ provider: "google", model: expect.stringContaining("gemini-3") }]);
407+
});
408+
310409
it("documents that cron preflight walks fallbacks before skipping", () => {
311410
const cliDocs = readFileSync("docs/cli/cron.md", "utf8");
312411
const automationDocs = readFileSync("docs/automation/cron-jobs.md", "utf8");

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export function resolveCronFallbacksOverride(params: {
1414
job: CronJob;
1515
agentId: string;
1616
useSubagentFallbacks?: boolean;
17+
isDefaultPrimaryShorthand?: boolean;
1718
}): string[] | undefined {
1819
const payload = params.job.payload.kind === "agentTurn" ? params.job.payload : undefined;
1920
const payloadFallbacks = Array.isArray(payload?.fallbacks) ? payload.fallbacks : undefined;
@@ -33,6 +34,13 @@ export function resolveCronFallbacksOverride(params: {
3334
return subagentFallbacksOverride;
3435
}
3536
}
37+
if (!hasCronPayloadModelOverride && params.isDefaultPrimaryShorthand === true) {
38+
// Default-primary shorthand: the agent uses the same string model as the
39+
// configured default primary and carries no explicit fallback policy.
40+
// For isolated cron, inherit default fallbacks instead of treating the
41+
// agent as strict.
42+
return undefined;
43+
}
3644
return resolveEffectiveModelFallbacks({
3745
cfg: params.cfg,
3846
agentId: params.agentId,
@@ -49,12 +57,14 @@ export function resolveCronPreflightCandidates(params: {
4957
provider: string;
5058
model: string;
5159
useSubagentFallbacks?: boolean;
60+
isDefaultPrimaryShorthand?: boolean;
5261
}): ModelCandidate[] {
5362
const fallbacksOverride = resolveCronFallbacksOverride({
5463
cfg: params.cfg,
5564
job: params.job,
5665
agentId: params.agentId,
5766
useSubagentFallbacks: params.useSubagentFallbacks,
67+
isDefaultPrimaryShorthand: params.isDefaultPrimaryShorthand,
5868
});
5969
return resolveModelCandidateChain({
6070
cfg: params.cfg,

src/cron/isolated-agent/run.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
/** Orchestrates isolated cron agent turn setup, execution, delivery, and cleanup. */
2-
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
2+
import {
3+
normalizeOptionalString,
4+
resolvePrimaryStringValue,
5+
} from "@openclaw/normalization-core/string-coerce";
36
import { retireSessionMcpRuntime } from "../../agents/agent-bundle-mcp-tools.js";
47
import { hasAnyAuthProfileStoreSource } from "../../agents/auth-profiles/source-check.js";
58
import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
@@ -492,6 +495,7 @@ type PreparedCronRunContext = {
492495
liveSelection: CronLiveSelection;
493496
useSubagentFallbacks: boolean;
494497
modelFallbacksOverride?: string[];
498+
isDefaultPrimaryShorthand: boolean;
495499
thinkLevel: ThinkLevel | undefined;
496500
timeoutMs: number;
497501
/**
@@ -540,6 +544,11 @@ async function prepareCronRunContext(params: {
540544
? resolveAgentConfig(runtimeCfg, normalizedRequested)
541545
: undefined;
542546
const agentId = normalizedRequested ?? defaultAgentId;
547+
const rawAgentModel = agentConfigOverride?.model;
548+
const defaultPrimary = resolvePrimaryStringValue(runtimeCfg.agents?.defaults?.model);
549+
const isDefaultPrimaryShorthand =
550+
typeof rawAgentModel === "string" &&
551+
resolvePrimaryStringValue(rawAgentModel) === defaultPrimary;
543552
const agentCfg: AgentDefaultsConfig = buildCronAgentDefaultsConfig({
544553
defaults: runtimeCfg.agents?.defaults,
545554
agentConfigOverride,
@@ -661,6 +670,7 @@ async function prepareCronRunContext(params: {
661670
provider,
662671
model,
663672
useSubagentFallbacks,
673+
isDefaultPrimaryShorthand,
664674
});
665675
let selectedPreflightCandidate: { provider: string; model: string } | undefined;
666676
let selectedPreflightCandidateIndex = -1;
@@ -911,6 +921,7 @@ async function prepareCronRunContext(params: {
911921
liveSelection,
912922
useSubagentFallbacks,
913923
modelFallbacksOverride,
924+
isDefaultPrimaryShorthand,
914925
thinkLevel,
915926
timeoutMs,
916927
runTimeoutOverrideMs,
@@ -1317,6 +1328,7 @@ export async function runCronIsolatedAgentTurn(params: {
13171328
agentPayload: prepared.context.agentPayload,
13181329
useSubagentFallbacks: prepared.context.useSubagentFallbacks,
13191330
modelFallbacksOverride: prepared.context.modelFallbacksOverride,
1331+
isDefaultPrimaryShorthand: prepared.context.isDefaultPrimaryShorthand,
13201332
agentVerboseDefault: prepared.context.agentCfg?.verboseDefault,
13211333
liveSelection: prepared.context.liveSelection,
13221334
cronSession: prepared.context.cronSession,

0 commit comments

Comments
 (0)