-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(rebuild): reconcile stale pinned session models after inference switch (#7102) #7109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
apurvvkumaria
merged 7 commits into
main
from
fix/7102-reconcile-stale-session-model-on-rebuild
Jul 17, 2026
Merged
Changes from 1 commit
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
26f5d5d
fix(rebuild): reconcile stale pinned session models after inference s…
yanyunl1991 520d3c0
fix(rebuild): harden session model reconciliation
jyaunches b4378e9
test(rebuild): cover session model reconciliation flow
jyaunches 2ccc4ed
test(rebuild): use descriptor-safe session reads
jyaunches 16a7fe4
test(rebuild): keep session assertions linear
jyaunches d0889ea
fix(rebuild): harden session model reconciliation
jyaunches 05965bb
Merge remote-tracking branch 'origin/main' into fix/7102-reconcile-st…
jyaunches File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { reconcilePinnedSessionModels } from "./reconcile-session-models"; | ||
|
|
||
| function store(entries: Record<string, unknown>): string { | ||
| return JSON.stringify(entries); | ||
| } | ||
|
|
||
| describe("reconcilePinnedSessionModels (#7102)", () => { | ||
| const primary = "inference/nvidia/llama-3.3-nemotron-super-49b-v1.5"; | ||
|
|
||
| it("clears a managed pin that no longer matches the current default", () => { | ||
| const raw = store({ | ||
| "agent:main:main": { | ||
| sessionId: "s1", | ||
| updatedAt: 1, | ||
| modelProvider: "inference", | ||
| model: "meta/llama-3.1-8b-instruct", | ||
| }, | ||
| }); | ||
| const result = reconcilePinnedSessionModels(raw, primary); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.clearedSessionKeys).toEqual(["agent:main:main"]); | ||
| const parsed = JSON.parse(result.content); | ||
| expect(parsed["agent:main:main"].model).toBeUndefined(); | ||
| expect(parsed["agent:main:main"].modelProvider).toBeUndefined(); | ||
| // Non-model fields are preserved. | ||
| expect(parsed["agent:main:main"].sessionId).toBe("s1"); | ||
| }); | ||
|
|
||
| it("leaves a session already on the current default untouched", () => { | ||
| const raw = store({ | ||
| "agent:main:main": { | ||
| modelProvider: "inference", | ||
| model: "nvidia/llama-3.3-nemotron-super-49b-v1.5", | ||
| }, | ||
| }); | ||
| const result = reconcilePinnedSessionModels(raw, primary); | ||
| expect(result.changed).toBe(false); | ||
| expect(result.content).toBe(raw); | ||
| }); | ||
|
|
||
| it("leaves an intentional non-managed provider pin untouched", () => { | ||
| const raw = store({ | ||
| "agent:main:main": { modelProvider: "openai", model: "gpt-5.6-terra" }, | ||
| }); | ||
| const result = reconcilePinnedSessionModels(raw, primary); | ||
| expect(result.changed).toBe(false); | ||
| }); | ||
|
|
||
| it("only clears the stale managed sessions in a mixed store", () => { | ||
| const raw = store({ | ||
| stale: { modelProvider: "inference", model: "meta/llama-3.1-8b-instruct" }, | ||
| current: { modelProvider: "inference", model: "nvidia/llama-3.3-nemotron-super-49b-v1.5" }, | ||
| intentional: { modelProvider: "openai", model: "gpt-5.6-terra" }, | ||
| unpinned: { sessionId: "x" }, | ||
| }); | ||
| const result = reconcilePinnedSessionModels(raw, primary); | ||
| expect(result.clearedSessionKeys).toEqual(["stale"]); | ||
| const parsed = JSON.parse(result.content); | ||
| expect(parsed.stale.model).toBeUndefined(); | ||
| expect(parsed.current.model).toBe("nvidia/llama-3.3-nemotron-super-49b-v1.5"); | ||
| expect(parsed.intentional.model).toBe("gpt-5.6-terra"); | ||
| expect(parsed.unpinned.sessionId).toBe("x"); | ||
| }); | ||
|
|
||
| it("is a no-op when the primary ref is missing", () => { | ||
| const raw = store({ | ||
| "agent:main:main": { modelProvider: "inference", model: "meta/llama-3.1-8b-instruct" }, | ||
| }); | ||
| expect(reconcilePinnedSessionModels(raw, null).changed).toBe(false); | ||
| }); | ||
|
|
||
| it("is a no-op on malformed session json", () => { | ||
| expect(reconcilePinnedSessionModels("not json", primary).changed).toBe(false); | ||
| expect(reconcilePinnedSessionModels("[]", primary).changed).toBe(false); | ||
| }); | ||
|
|
||
| it("ignores an entry with a non-string model", () => { | ||
| const raw = store({ | ||
| "agent:main:main": { modelProvider: "inference", model: 42 }, | ||
| }); | ||
| expect(reconcilePinnedSessionModels(raw, primary).changed).toBe(false); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { MANAGED_PROVIDER_ID } from "../../inference/config"; | ||
| import { executeSandboxCommand } from "./process-recovery"; | ||
| import type { RebuildLog } from "./rebuild-credential-preflight"; | ||
| import { DEFAULT_AGENT_ID } from "./sessions/paths"; | ||
|
|
||
| const OPENCLAW_CONFIG_PATH = "/sandbox/.openclaw/openclaw.json"; | ||
|
|
||
| function defaultAgentSessionsPath(agentId: string): string { | ||
| return `/sandbox/.openclaw/agents/${agentId}/sessions/sessions.json`; | ||
| } | ||
|
|
||
| export interface SessionModelReconcileResult { | ||
| changed: boolean; | ||
| content: string; | ||
| clearedSessionKeys: string[]; | ||
| } | ||
|
|
||
| /** | ||
| * #7102: OpenClaw pins a `{ modelProvider, model }` on each stored session in | ||
| * `agents/<id>/sessions/sessions.json`. `nemoclaw inference set` + `rebuild` | ||
| * update the config default (`agents.defaults.model.primary`) but leave those | ||
| * per-session pins on the pre-switch model, so the TUI status bar shows the old | ||
| * model when it resumes the last session on the first connect after a switch. | ||
| * | ||
| * This reconciles the persisted pins: for a session whose pin is the *managed* | ||
| * provider and no longer matches the current default, clear the pin so the | ||
| * session falls back to the config default — matching OpenClaw's own clean-entry | ||
| * semantics after `sessions.reset`. Sessions already on the default, and | ||
| * sessions pinned to a different provider (an intentional per-session choice), | ||
| * are left untouched. Pure so the contract is unit-tested without a sandbox. | ||
| */ | ||
| export function reconcilePinnedSessionModels( | ||
| sessionsRaw: string, | ||
| primaryModelRef: string | null, | ||
| ): SessionModelReconcileResult { | ||
| const noChange: SessionModelReconcileResult = { | ||
| changed: false, | ||
| content: sessionsRaw, | ||
| clearedSessionKeys: [], | ||
| }; | ||
| if (!primaryModelRef) return noChange; | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(sessionsRaw); | ||
| } catch { | ||
| return noChange; | ||
| } | ||
| if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return noChange; | ||
| const store = parsed as Record<string, unknown>; | ||
| const clearedSessionKeys: string[] = []; | ||
| for (const [key, entry] of Object.entries(store)) { | ||
| if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; | ||
| const record = entry as Record<string, unknown>; | ||
| const provider = record.modelProvider; | ||
| const model = record.model; | ||
| // Only touch pins on the managed provider; a different provider is an | ||
| // intentional per-session choice. | ||
| if (provider !== MANAGED_PROVIDER_ID || typeof model !== "string") continue; | ||
| // Already following the current default → nothing to reconcile. | ||
| if (`${provider}/${model}` === primaryModelRef) continue; | ||
| delete record.model; | ||
| delete record.modelProvider; | ||
| clearedSessionKeys.push(key); | ||
| } | ||
| if (clearedSessionKeys.length === 0) return noChange; | ||
| return { | ||
| changed: true, | ||
| content: `${JSON.stringify(store, null, 2)}\n`, | ||
| clearedSessionKeys, | ||
| }; | ||
| } | ||
|
|
||
| function readPrimaryModelRef(sandboxName: string): string | null { | ||
| const res = executeSandboxCommand(sandboxName, `cat ${OPENCLAW_CONFIG_PATH} 2>/dev/null`); | ||
| if (!res || res.status !== 0 || !res.stdout.trim()) return null; | ||
| try { | ||
| const config = JSON.parse(res.stdout) as { | ||
| agents?: { defaults?: { model?: { primary?: unknown } } }; | ||
| }; | ||
| const primary = config.agents?.defaults?.model?.primary; | ||
| return typeof primary === "string" ? primary : null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Best-effort reconcile of stale pinned session models after a rebuild restore. | ||
| * MUST run in the post-restore window (gateway down): OpenClaw owns | ||
| * `sessions.json` while it is live, so editing it there would race its writes. | ||
| */ | ||
| export function reconcileStalePinnedSessionModelsAfterRebuild( | ||
| sandboxName: string, | ||
| log: RebuildLog, | ||
| ): void { | ||
| const primary = readPrimaryModelRef(sandboxName); | ||
| if (!primary) { | ||
| log("Session model reconcile skipped: could not read agents.defaults.model.primary"); | ||
| return; | ||
| } | ||
| const sessionsPath = defaultAgentSessionsPath(DEFAULT_AGENT_ID); | ||
| const readResult = executeSandboxCommand(sandboxName, `cat ${sessionsPath} 2>/dev/null`); | ||
| if (!readResult || readResult.status !== 0 || !readResult.stdout.trim()) { | ||
| log(`Session model reconcile skipped: no session store at ${sessionsPath}`); | ||
| return; | ||
| } | ||
| const reconciled = reconcilePinnedSessionModels(readResult.stdout, primary); | ||
| if (!reconciled.changed) { | ||
| log("Session model reconcile: no stale pinned session models"); | ||
| return; | ||
| } | ||
| const encoded = Buffer.from(reconciled.content, "utf8").toString("base64"); | ||
| const tmpPath = `${sessionsPath}.nemoclaw-tmp`; | ||
| const writeResult = executeSandboxCommand( | ||
| sandboxName, | ||
| `printf %s '${encoded}' | base64 -d > ${tmpPath} && mv ${tmpPath} ${sessionsPath}`, | ||
| ); | ||
| if (!writeResult || writeResult.status !== 0) { | ||
| log( | ||
| `Session model reconcile: failed to write ${sessionsPath} (status=${writeResult?.status ?? "null"})`, | ||
| ); | ||
| return; | ||
| } | ||
| log( | ||
| `Session model reconcile: cleared stale pinned model on ${reconciled.clearedSessionKeys.length} session(s) so they follow ${primary}`, | ||
| ); | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.