From 80bf0e21ad33650f809a9f10b7aa27033039b233 Mon Sep 17 00:00:00 2001 From: zjmy Date: Sat, 21 Feb 2026 08:01:28 +0800 Subject: [PATCH] fix: prevent heartbeat model override from bleeding into main session When heartbeat.model is configured with a different model (e.g. a local Ollama model), the heartbeat run's model/provider/contextTokens were being persisted to the session store via persistSessionUsageUpdate(). This caused the main session to inherit the heartbeat model's smaller context window on the next user request, triggering aggressive compaction and a compounding feedback loop. Fix: Add an isHeartbeat flag to persistSessionUsageUpdate(). When true, token usage counters are still recorded but model/provider/contextTokens fields are preserved from the existing session entry rather than being overwritten with the heartbeat model's values. Fixes #22133 --- src/auto-reply/reply/agent-runner.ts | 1 + src/auto-reply/reply/followup-runner.ts | 1 + src/auto-reply/reply/session-usage.ts | 23 +++++++++++++++++++---- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 8b126382dbc..3fc1924d993 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -466,6 +466,7 @@ export async function runReplyAgent(params: { contextTokensUsed, systemPromptReport: runResult.meta?.systemPromptReport, cliSessionId, + isHeartbeat, }); // Drain any late tool/block deliveries before deciding there's "nothing to send". diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 0d796f37dae..d90de9a201f 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -252,6 +252,7 @@ export function createFollowupRunner(params: { contextTokensUsed, systemPromptReport: runResult.meta?.systemPromptReport, logLabel: "followup", + isHeartbeat: opts?.isHeartbeat === true, }); } diff --git a/src/auto-reply/reply/session-usage.ts b/src/auto-reply/reply/session-usage.ts index 6638a6738ef..8e4b9458aa4 100644 --- a/src/auto-reply/reply/session-usage.ts +++ b/src/auto-reply/reply/session-usage.ts @@ -50,6 +50,13 @@ export async function persistSessionUsageUpdate(params: { systemPromptReport?: SessionSystemPromptReport; cliSessionId?: string; logLabel?: string; + /** + * When true, the model/provider/contextTokens fields are NOT persisted to + * the session entry. This prevents heartbeat model overrides from bleeding + * into the main session's stored state (model, context window, etc.). + * Token usage counters are still recorded. + */ + isHeartbeat?: boolean; }): Promise { const { storePath, sessionKey } = params; if (!storePath || !sessionKey) { @@ -84,9 +91,15 @@ export async function persistSessionUsageUpdate(params: { }) : undefined; const patch: Partial = { - modelProvider: params.providerUsed ?? entry.modelProvider, - model: params.modelUsed ?? entry.model, - contextTokens: resolvedContextTokens, + // When isHeartbeat is true, preserve the session's existing model/provider/context + // so that a heartbeat model override does not bleed into the main session state. + ...(params.isHeartbeat + ? {} + : { + modelProvider: params.providerUsed ?? entry.modelProvider, + model: params.modelUsed ?? entry.model, + contextTokens: resolvedContextTokens, + }), systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport, updatedAt: Date.now(), }; @@ -112,7 +125,9 @@ export async function persistSessionUsageUpdate(params: { return; } - if (params.modelUsed || params.contextTokensUsed) { + // When isHeartbeat is true, skip persisting model/context entirely — the heartbeat + // model override should not affect the session's stored model state. + if (!params.isHeartbeat && (params.modelUsed || params.contextTokensUsed)) { try { await updateSessionStoreEntry({ storePath,