From 65f7eed1feed5fc1c48735f5dd9a614b5a1c778d Mon Sep 17 00:00:00 2001 From: rudaev Date: Sun, 5 Apr 2026 13:15:02 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20heartbeat=20model=20override=20ignored?= =?UTF-8?q?=20=E2=80=94=204=20root=20causes=20(#56788)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies source-level fixes from upstream PRs #57094 and #57076 (both unmerged) via a build-time patch script. Root causes and fixes: 1. runtime-system.ts: plugin runtime API stripped heartbeat.model when forwarding to runHeartbeatOnceInternal — now passes it through 2. live-model-switch.ts: resolveLiveSessionModelSelection ignored caller- provided defaults when agentId was present, always using config default — now prefers caller defaults (the resolved heartbeat model) 3. model-fallback.ts: LiveSessionModelSwitchError was swallowed as a candidate failure, inverting the fallback order — now rethrown when rethrowLiveSwitch is set 4. get-reply.ts: post-directive resolution unconditionally overwrote the heartbeat model — now guarded by hasResolvedHeartbeatModelOverride Also in agent-runner-execution.ts: uses structural isLiveSessionModelSwitchError check (cross-module safe) and passes rethrowLiveSwitch: true. Upstream refs: openclaw/openclaw#56788, PR #57094, PR #57076 --- Dockerfile.base | 4 + patches/apply-heartbeat-model-fix.sh | 166 +++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 patches/apply-heartbeat-model-fix.sh diff --git a/Dockerfile.base b/Dockerfile.base index 9e2b8f4..47cde01 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -21,6 +21,10 @@ WORKDIR /openclaw ARG OPENCLAW_GIT_REF=main RUN git clone --depth 1 --branch "${OPENCLAW_GIT_REF}" https://github.com/openclaw/openclaw.git . +# Patch: fix heartbeat model override ignored (#56788) +COPY patches/apply-heartbeat-model-fix.sh /tmp/apply-heartbeat-model-fix.sh +RUN bash /tmp/apply-heartbeat-model-fix.sh + # Patch: relax version requirements for packages using workspace protocol. RUN set -eux; \ find ./extensions -name 'package.json' -type f | while read -r f; do \ diff --git a/patches/apply-heartbeat-model-fix.sh b/patches/apply-heartbeat-model-fix.sh new file mode 100644 index 0000000..d82fa2b --- /dev/null +++ b/patches/apply-heartbeat-model-fix.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# Fix: heartbeat model override ignored (#56788) +# Applies changes from upstream PRs #57094 and #57076 (both unmerged as of 2026-04-05). +# +# Root causes (4 loss points in the model resolution chain): +# 1. runtime-system.ts strips heartbeat.model when forwarding to runHeartbeatOnceInternal +# 2. live-model-switch.ts ignores caller-provided defaults, uses config default instead +# 3. model-fallback.ts swallows LiveSessionModelSwitchError as candidate failure +# 4. get-reply.ts unconditionally overwrites heartbeat model after directive resolution +set -euo pipefail + +echo "[patch] Applying heartbeat model override fix (#56788)..." +echo "[patch] Based on upstream PRs #57094 + #57076" + +# ── Fix 1: runtime-system.ts — pass model field through (#57076) ──────────── +FILE="src/plugins/runtime/runtime-system.ts" +if [ -f "$FILE" ]; then + perl -i -pe 's/heartbeat: heartbeat \? \{ target: heartbeat\.target \} : undefined/heartbeat: heartbeat ? { target: heartbeat.target, model: heartbeat.model } : undefined/' "$FILE" + echo "[patch] Fixed $FILE" +else + echo "[patch] WARNING: $FILE not found" +fi + +# ── Fix 1b: types-core.ts — add model to heartbeat type (#57076) ─────────── +FILE="src/plugins/runtime/types-core.ts" +if [ -f "$FILE" ]; then + perl -i -pe 's/heartbeat\?: \{ target\?: string \}/heartbeat?: { target?: string; model?: string }/' "$FILE" + echo "[patch] Fixed $FILE" +else + echo "[patch] WARNING: $FILE not found" +fi + +# ── Fix 2: live-model-switch.ts — prefer caller-provided defaults (#57076) ── +FILE="src/agents/live-model-switch.ts" +if [ -f "$FILE" ]; then + perl -0777 -i -pe 's{ + const\s+defaultModelRef\s*=\s*agentId\s*\n\s*\?\s*resolveDefaultModelForAgent\(\{\s*\n\s*cfg,\s*\n\s*agentId,\s*\n\s*\}\)\s*\n\s*:\s*\{\s*provider:\s*params\.defaultProvider,\s*model:\s*params\.defaultModel\s*\}; +}{ const defaultModelRef = + params.defaultProvider \&\& params.defaultModel + ? { provider: params.defaultProvider, model: params.defaultModel } + : agentId + ? resolveDefaultModelForAgent({ cfg, agentId }) + : { provider: params.defaultProvider, model: params.defaultModel };}xms' "$FILE" + echo "[patch] Fixed $FILE" +else + echo "[patch] WARNING: $FILE not found" +fi + +# ── Fix 3: model-fallback.ts — rethrow LiveSessionModelSwitchError (#57094) ─ +FILE="src/agents/model-fallback.ts" +if [ -f "$FILE" ]; then + # Use node for complex multi-site patching — safer than nested perl + node -e ' +const fs = require("fs"); +let code = fs.readFileSync(process.argv[1], "utf8"); + +// 3a: Add isLiveSessionModelSwitchError export after the log line +const checkFn = ` + +/** + * Structural check for LiveSessionModelSwitchError that works across + * module-boundary duplicates where instanceof would fail. + */ +export function isLiveSessionModelSwitchError(err) { + return ( + typeof err === "object" && + err !== null && + err.name === "LiveSessionModelSwitchError" && + typeof err.provider === "string" && + typeof err.model === "string" + ); +}`; + +code = code.replace( + /const log = createSubsystemLogger\("model-fallback"\);/, + `const log = createSubsystemLogger("model-fallback");${checkFn}` +); + +// 3b: Add rethrowLiveSwitch to runFallbackCandidate params and catch block +code = code.replace( + /async function runFallbackCandidate\(params: \{\n(\s+run: ModelFallbackRunFn;\n\s+provider: string;\n\s+model: string;\n\s+options\?: ModelFallbackRunOptions;)\n\}/, + `async function runFallbackCandidate(params: {\n$1\n rethrowLiveSwitch?: boolean;\n})` +); + +// Add rethrow before the normalize line +code = code.replace( + /( \} catch \(err\) \{\n)( \/\/ Normalize abort-wrapped rate-limit errors)/, + `$1 if (params.rethrowLiveSwitch && isLiveSessionModelSwitchError(err)) {\n throw err;\n }\n$2` +); + +// 3c: Add rethrowLiveSwitch to runFallbackAttempt params and passthrough +code = code.replace( + /async function runFallbackAttempt\(params: \{\n(\s+run: ModelFallbackRunFn;\n\s+provider: string;\n\s+model: string;\n\s+attempts: FallbackAttempt\[\];\n\s+options\?: ModelFallbackRunOptions;)\n\}/, + `async function runFallbackAttempt(params: {\n$1\n rethrowLiveSwitch?: boolean;\n})` +); + +code = code.replace( + /const runResult = await runFallbackCandidate\(\{\n\s+run: params\.run,\n\s+provider: params\.provider,\n\s+model: params\.model,\n\s+options: params\.options,\n\s+\}\);/, + `const runResult = await runFallbackCandidate({\n run: params.run,\n provider: params.provider,\n model: params.model,\n options: params.options,\n rethrowLiveSwitch: params.rethrowLiveSwitch,\n });` +); + +// 3d: Add rethrowLiveSwitch to runWithModelFallback signature +code = code.replace( + "onError?: ModelFallbackErrorHandler;\n}): Promise>", + "onError?: ModelFallbackErrorHandler;\n rethrowLiveSwitch?: boolean;\n}): Promise>" +); + +// 3e: Pass rethrowLiveSwitch in first runFallbackAttempt call (with options: runOptions) +code = code.replace( + /const attemptRun = await runFallbackAttempt\(\{\n\s+run: params\.run,\n\s+\.\.\.candidate,\n\s+attempts,\n\s+options: runOptions,\n\s+\}\);/, + `const attemptRun = await runFallbackAttempt({\n run: params.run,\n ...candidate,\n attempts,\n options: runOptions,\n rethrowLiveSwitch: params.rethrowLiveSwitch,\n });` +); + +fs.writeFileSync(process.argv[1], code, "utf8"); +' "$FILE" + echo "[patch] Fixed $FILE" +else + echo "[patch] WARNING: $FILE not found" +fi + +# ── Fix 4: agent-runner-execution.ts — structural check + rethrowLiveSwitch (#57094) ─ +FILE="src/auto-reply/reply/agent-runner-execution.ts" +if [ -f "$FILE" ]; then + node -e ' +const fs = require("fs"); +let code = fs.readFileSync(process.argv[1], "utf8"); + +// 4a: Replace import — remove LiveSessionModelSwitchError, add isLiveSessionModelSwitchError +code = code.replace( + /import \{ LiveSessionModelSwitchError \} from "\.\.\/\.\.\/agents\/live-model-switch-error\.js";\n/, + "" +); +code = code.replace( + /import \{ runWithModelFallback, isFallbackSummaryError \} from "\.\.\/\.\.\/agents\/model-fallback\.js";/, + `import {\n runWithModelFallback,\n isFallbackSummaryError,\n isLiveSessionModelSwitchError,\n} from "../../agents/model-fallback.js";` +); + +// 4b: Pass rethrowLiveSwitch: true to runWithModelFallback +code = code.replace( + /\.\.\.resolveModelFallbackOptions\(params\.followupRun\.run\),\n(\s+)runId,/, + `...resolveModelFallbackOptions(params.followupRun.run),\n$1runId,\n$1rethrowLiveSwitch: true,` +); + +// 4c: Replace instanceof check with structural check +code = code.replace( + /if \(err instanceof LiveSessionModelSwitchError\) \{/g, + "if (isLiveSessionModelSwitchError(err)) {" +); + +fs.writeFileSync(process.argv[1], code, "utf8"); +' "$FILE" + echo "[patch] Fixed $FILE" +else + echo "[patch] WARNING: $FILE not found" +fi + +# ── Fix 5: get-reply.ts — guard post-directive model overwrite (#57076) ───── +FILE="src/auto-reply/reply/get-reply.ts" +if [ -f "$FILE" ]; then + perl -0777 -i -pe 's{(\} = directiveResult\.result;\n)( provider = resolvedProvider;\n model = resolvedModel;)}{$1 if (!hasResolvedHeartbeatModelOverride) \{\n provider = resolvedProvider;\n model = resolvedModel;\n \}}s' "$FILE" + echo "[patch] Fixed $FILE" +else + echo "[patch] WARNING: $FILE not found" +fi + +echo "[patch] Heartbeat model override fix applied (5 files, 4 root causes)."