From b17bb63b9eaac43636085b75012b8a5509ddb972 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 01:03:58 +0100 Subject: [PATCH] fix: repair stale session route state in doctor --- CHANGELOG.md | 1 + docs/gateway/doctor.md | 6 + extensions/codex/doctor-contract-api.ts | 12 + .../doctor-session-state-providers.test.ts | 236 ++++++++ .../doctor-session-state-providers.ts | 512 ++++++++++++++++++ src/commands/doctor-state-integrity.ts | 11 + src/plugin-sdk/runtime-doctor.ts | 1 + src/plugins/doctor-contract-registry.test.ts | 31 ++ src/plugins/doctor-contract-registry.ts | 77 ++- .../doctor-session-route-state-owner-types.ts | 8 + 10 files changed, 894 insertions(+), 1 deletion(-) create mode 100644 extensions/codex/doctor-contract-api.ts create mode 100644 src/commands/doctor-session-state-providers.test.ts create mode 100644 src/commands/doctor-session-state-providers.ts create mode 100644 src/plugins/doctor-session-route-state-owner-types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb550dc298..855006ad42d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai - Mattermost: clarify that the model picker only changes the session model and that runtime switches require `/oc_model --runtime `. Thanks @vincentkoc. - Doctor/config: keep active `auth.profiles` metadata intact when `doctor --fix` strips stale secret fields from configs, repairing legacy `:default` API-key profile metadata when model fallbacks or explicit `model@profile` refs still depend on it. Fixes #77400. - Doctor/plugins: include `plugins.allow`-only official plugin ids in the release configured-plugin repair set, so `doctor --fix` installs official external plugins that are configured but not yet loaded instead of removing them as stale allow entries. Fixes #77155. Thanks @hclsys. +- Doctor/sessions: clear auto-created stale session routing state from the sessions store when `doctor --fix` sees plugin-owned model/runtime/auth/session bindings outside the current configured route, while leaving explicit user model choices for manual review. Refs #68615. - CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc. - CLI/update: use an absolute POSIX npm script shell during package-manager updates, so restricted PATH environments can still run dependency lifecycle scripts while updating from `--tag main`. Fixes #77530. Thanks @PeterTremonti. - Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 35389f89d9a..3382097ad8d 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -271,6 +271,12 @@ That stages grounded durable candidates into the short-term dreaming store while If the warning appears, choose the route you intended and edit config manually. Keep the warning as-is when PI Codex OAuth is intentional. + + + Doctor also scans the active sessions store for stale auto-created route state after you move the configured default/fallback model or runtime away from a plugin-owned route such as Codex. + + `openclaw doctor --fix` can clear auto-created stale state such as `modelOverrideSource: "auto"` model pins, runtime model metadata, pinned harness ids, CLI session bindings, and auto auth-profile overrides when their owning route is no longer configured. Explicit user or legacy session model choices are reported for manual review and left untouched; switch them with `/model ...`, `/new`, or reset the session when that route is no longer intended. + Doctor can migrate older on-disk layouts into the current structure: diff --git a/extensions/codex/doctor-contract-api.ts b/extensions/codex/doctor-contract-api.ts new file mode 100644 index 00000000000..1ef0ab52183 --- /dev/null +++ b/extensions/codex/doctor-contract-api.ts @@ -0,0 +1,12 @@ +import type { DoctorSessionRouteStateOwner } from "openclaw/plugin-sdk/runtime-doctor"; + +export const sessionRouteStateOwners: DoctorSessionRouteStateOwner[] = [ + { + id: "codex", + label: "Codex", + providerIds: ["codex", "codex-cli", "openai-codex"], + runtimeIds: ["codex", "codex-cli"], + cliSessionKeys: ["codex-cli"], + authProfilePrefixes: ["codex:", "codex-cli:", "openai-codex:"], + }, +]; diff --git a/src/commands/doctor-session-state-providers.test.ts b/src/commands/doctor-session-state-providers.test.ts new file mode 100644 index 00000000000..786b3ccec5c --- /dev/null +++ b/src/commands/doctor-session-state-providers.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest"; +import { + applySessionRouteStateRepair, + resolveConfiguredDoctorSessionStateRoute, + scanSessionRouteStateOwners, +} from "./doctor-session-state-providers.js"; + +const codexOwner = { + id: "codex", + label: "Codex", + providerIds: ["codex", "codex-cli", "openai-codex"], + runtimeIds: ["codex", "codex-cli"], + cliSessionKeys: ["codex-cli"], + authProfilePrefixes: ["codex:", "codex-cli:", "openai-codex:"], +}; + +describe("doctor session state provider routes", () => { + it("preserves raw configured CLI runtimes before harness policy normalization", () => { + expect( + resolveConfiguredDoctorSessionStateRoute({ + cfg: { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + agentRuntime: { id: "codex-cli" }, + }, + }, + }, + sessionKey: "agent:main:telegram:direct:1", + env: {}, + }), + ).toMatchObject({ + defaultProvider: "openai", + configuredModelRefs: ["openai/gpt-5.5"], + runtime: "codex-cli", + }); + }); + + it("lets environment CLI runtime overrides reach plugin-owned scanners", () => { + expect( + resolveConfiguredDoctorSessionStateRoute({ + cfg: { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + agentRuntime: { id: "pi" }, + }, + }, + }, + sessionKey: "agent:main:telegram:direct:1", + env: { OPENCLAW_AGENT_RUNTIME: "codex-cli" }, + }), + ).toMatchObject({ + runtime: "codex-cli", + }); + }); + + it("clears auto-created route state when current route no longer uses the owner", () => { + const sessionKey = "agent:main:telegram:direct:1"; + const entry: Record = { + sessionId: "sess-stale-codex", + updatedAt: 1, + providerOverride: "openai-codex", + modelOverride: "gpt-5.4", + modelOverrideSource: "auto", + modelProvider: "openai-codex", + model: "gpt-5.4", + contextTokens: 1_050_000, + systemPromptReport: { source: "run" }, + fallbackNoticeSelectedModel: "github-copilot/gpt-5-mini", + fallbackNoticeActiveModel: "openai-codex/gpt-5.4", + fallbackNoticeReason: "rate-limit", + agentHarnessId: "codex", + authProfileOverride: "openai-codex:default", + authProfileOverrideSource: "auto", + authProfileOverrideCompactionCount: 2, + cliSessionBindings: { + "codex-cli": { sessionId: "codex-session-1" }, + "claude-cli": { sessionId: "claude-session-1" }, + }, + cliSessionIds: { + "codex-cli": "codex-session-1", + "claude-cli": "claude-session-1", + }, + }; + + const scan = scanSessionRouteStateOwners({ + owners: [codexOwner], + store: { [sessionKey]: entry }, + routes: { + [sessionKey]: { + defaultProvider: "github-copilot", + configuredModelRefs: ["github-copilot/gpt-5-mini"], + runtime: "pi", + }, + }, + }); + + expect(scan.manualReview).toEqual([]); + expect(scan.repairs).toEqual([ + { + key: sessionKey, + ownerId: "codex", + ownerLabel: "Codex", + cliSessionKeys: ["codex-cli"], + reasons: [ + "auto model override", + "runtime model state", + "pinned runtime", + "CLI session binding", + "auto auth profile override", + ], + }, + ]); + + expect(applySessionRouteStateRepair({ entry, repair: scan.repairs[0], now: 123 })).toBe(true); + expect(entry).toMatchObject({ + sessionId: "sess-stale-codex", + updatedAt: 123, + cliSessionBindings: { + "claude-cli": { sessionId: "claude-session-1" }, + }, + cliSessionIds: { + "claude-cli": "claude-session-1", + }, + }); + expect(entry.providerOverride).toBeUndefined(); + expect(entry.modelOverride).toBeUndefined(); + expect(entry.modelOverrideSource).toBeUndefined(); + expect(entry.modelProvider).toBeUndefined(); + expect(entry.model).toBeUndefined(); + expect(entry.contextTokens).toBeUndefined(); + expect(entry.systemPromptReport).toBeUndefined(); + expect(entry.agentHarnessId).toBeUndefined(); + expect(entry.authProfileOverride).toBeUndefined(); + expect(entry.authProfileOverrideSource).toBeUndefined(); + expect(entry.authProfileOverrideCompactionCount).toBeUndefined(); + expect(entry.fallbackNoticeActiveModel).toBeUndefined(); + }); + + it("leaves explicit user owner model choices for manual review", () => { + const sessionKey = "agent:main:telegram:direct:2"; + const entry: Record = { + sessionId: "sess-user-codex", + updatedAt: 1, + providerOverride: "openai-codex", + modelOverride: "gpt-5.4", + modelOverrideSource: "user", + modelProvider: "openai-codex", + model: "gpt-5.4", + agentHarnessId: "codex", + cliSessionBindings: { + "codex-cli": { sessionId: "codex-session-2" }, + }, + }; + + const scan = scanSessionRouteStateOwners({ + owners: [codexOwner], + store: { [sessionKey]: entry }, + routes: { + [sessionKey]: { + defaultProvider: "github-copilot", + configuredModelRefs: ["github-copilot/gpt-5-mini"], + runtime: "pi", + }, + }, + }); + + expect(scan.repairs).toEqual([]); + expect(scan.manualReview).toEqual([ + { + key: sessionKey, + ownerLabel: "Codex", + message: `${sessionKey} (openai-codex/gpt-5.4, user)`, + }, + ]); + }); + + it("keeps owner state when owner remains in the configured route", () => { + const sessionKey = "agent:main:telegram:direct:3"; + const entry: Record = { + sessionId: "sess-configured-codex", + updatedAt: 1, + providerOverride: "openai-codex", + modelOverride: "gpt-5.4", + modelOverrideSource: "auto", + modelProvider: "openai-codex", + model: "gpt-5.4", + agentHarnessId: "codex", + cliSessionBindings: { + "codex-cli": { sessionId: "codex-session-3" }, + }, + }; + + const scan = scanSessionRouteStateOwners({ + owners: [codexOwner], + store: { [sessionKey]: entry }, + routes: { + [sessionKey]: { + defaultProvider: "github-copilot", + configuredModelRefs: ["github-copilot/gpt-5-mini", "openai-codex/gpt-5.4"], + runtime: "pi", + }, + }, + }); + + expect(scan).toEqual({ repairs: [], manualReview: [] }); + }); + + it("keeps owner CLI state when owner runtime is still configured", () => { + const sessionKey = "agent:main:telegram:direct:4"; + const entry: Record = { + sessionId: "sess-codex-cli", + updatedAt: 1, + modelProvider: "codex-cli", + model: "gpt-5.5", + cliSessionBindings: { + "codex-cli": { sessionId: "codex-cli-session" }, + }, + }; + + const scan = scanSessionRouteStateOwners({ + owners: [codexOwner], + store: { [sessionKey]: entry }, + routes: { + [sessionKey]: { + defaultProvider: "openai", + configuredModelRefs: ["openai/gpt-5.5"], + runtime: "codex-cli", + }, + }, + }); + + expect(scan).toEqual({ repairs: [], manualReview: [] }); + }); +}); diff --git a/src/commands/doctor-session-state-providers.ts b/src/commands/doctor-session-state-providers.ts new file mode 100644 index 00000000000..e35bf45d7dc --- /dev/null +++ b/src/commands/doctor-session-state-providers.ts @@ -0,0 +1,512 @@ +import { resolveAgentRuntimePolicy } from "../agents/agent-runtime-policy.js"; +import { + listAgentEntries, + resolveAgentModelFallbacksOverride, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; +import { resolveAgentHarnessPolicy } from "../agents/harness/selection.js"; +import { + modelKey, + normalizeProviderId, + parseModelRef, + resolveDefaultModelForAgent, +} from "../agents/model-selection.js"; +import { resolveAgentModelFallbackValues } from "../config/model-input.js"; +import type { SessionEntry } from "../config/sessions.js"; +import { updateSessionStore } from "../config/sessions/store.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { listPluginDoctorSessionRouteStateOwners } from "../plugins/doctor-contract-registry.js"; +import type { DoctorSessionRouteStateOwner } from "../plugins/doctor-session-route-state-owner-types.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; +import { note } from "../terminal/note.js"; + +type DoctorPrompterLike = { + confirmRuntimeRepair: (params: { + message: string; + initialValue?: boolean; + requiresInteractiveConfirmation?: boolean; + }) => Promise; + note?: typeof note; +}; + +function countLabel(count: number, singular: string, plural = `${singular}s`): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +function normalizeString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function normalizeIdSet(values: readonly string[] | undefined): Set { + return new Set((values ?? []).map((value) => normalizeProviderId(value))); +} + +function normalizePrefixList(values: readonly string[] | undefined): string[] { + return (values ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean); +} + +function ownsPrefixedValue(prefixes: readonly string[], value: unknown): boolean { + const normalized = normalizeString(value)?.toLowerCase(); + return normalized !== undefined && prefixes.some((prefix) => normalized.startsWith(prefix)); +} + +function countSessionLabel(count: number): string { + return countLabel(count, "session"); +} + +function repairExample(repair: DoctorSessionRouteStateRepair): string { + return `${repair.key} (${repair.reasons.join(", ")})`; +} + +function resolveSessionAgentId(cfg: OpenClawConfig, sessionKey: string): string { + return parseAgentSessionKey(sessionKey)?.agentId ?? resolveDefaultAgentId(cfg); +} + +function resolveRawConfiguredRuntime(params: { + cfg: OpenClawConfig; + agentId: string; + env?: NodeJS.ProcessEnv; +}): string | undefined { + const envRuntime = params.env?.OPENCLAW_AGENT_RUNTIME?.trim(); + if (envRuntime) { + return normalizeProviderId(envRuntime); + } + const agentRuntime = resolveAgentRuntimePolicy( + listAgentEntries(params.cfg).find( + (entry) => normalizeAgentId(entry.id) === normalizeAgentId(params.agentId), + ), + )?.id?.trim(); + if (agentRuntime) { + return normalizeProviderId(agentRuntime); + } + const defaultsRuntime = resolveAgentRuntimePolicy(params.cfg.agents?.defaults)?.id?.trim(); + return defaultsRuntime ? normalizeProviderId(defaultsRuntime) : undefined; +} + +export function resolveConfiguredDoctorSessionStateRoute(params: { + cfg: OpenClawConfig; + sessionKey: string; + env?: NodeJS.ProcessEnv; +}): DoctorSessionRouteState { + const agentId = resolveSessionAgentId(params.cfg, params.sessionKey); + const primary = resolveDefaultModelForAgent({ cfg: params.cfg, agentId }); + const configuredModelRefs = new Set(); + const addRef = (provider: string, model: string) => { + configuredModelRefs.add(modelKey(provider, model)); + }; + addRef(primary.provider, primary.model); + const fallbacks = + resolveAgentModelFallbacksOverride(params.cfg, agentId) ?? + resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model); + for (const fallback of fallbacks) { + const parsed = parseModelRef(fallback, primary.provider, { + allowPluginNormalization: false, + }); + if (parsed) { + addRef(parsed.provider, parsed.model); + } + } + const runtime = resolveAgentHarnessPolicy({ + config: params.cfg, + agentId, + sessionKey: params.sessionKey, + env: params.env, + }).runtime; + return { + defaultProvider: primary.provider, + configuredModelRefs: [...configuredModelRefs], + runtime: resolveRawConfiguredRuntime({ cfg: params.cfg, agentId, env: params.env }) ?? runtime, + }; +} + +function resolvePluginDoctorSessionRouteStateOwners(params: { + env?: NodeJS.ProcessEnv; +}): DoctorSessionRouteStateOwner[] { + return listPluginDoctorSessionRouteStateOwners({ env: params.env }); +} + +export type DoctorSessionRouteState = { + defaultProvider: string; + configuredModelRefs: string[]; + runtime?: string; +}; + +export type DoctorSessionRouteStateRepair = { + key: string; + ownerId: string; + ownerLabel: string; + reasons: string[]; + cliSessionKeys: string[]; +}; + +export type DoctorSessionRouteStateManualReview = { + key: string; + ownerLabel: string; + message: string; +}; + +export type DoctorSessionRouteStateScan = { + repairs: DoctorSessionRouteStateRepair[]; + manualReview: DoctorSessionRouteStateManualReview[]; +}; + +function resolvePersistedOverrideModelRef(params: { + defaultProvider: string; + overrideProvider?: unknown; + overrideModel?: unknown; +}): { provider: string; model: string } | null { + const overrideModel = normalizeString(params.overrideModel); + if (!overrideModel) { + return null; + } + const overrideProvider = normalizeString(params.overrideProvider); + return parseModelRef( + overrideProvider ? `${overrideProvider}/${overrideModel}` : overrideModel, + params.defaultProvider, + { allowPluginNormalization: false }, + ); +} + +function addReason(reasons: string[], reason: string) { + if (!reasons.includes(reason)) { + reasons.push(reason); + } +} + +function routeAllowsOwnerState(params: { + owner: DoctorSessionRouteStateOwner; + route: DoctorSessionRouteState | undefined; +}): boolean { + const providerIds = normalizeIdSet(params.owner.providerIds); + const runtimeIds = normalizeIdSet(params.owner.runtimeIds); + const routeRuntime = normalizeString(params.route?.runtime); + if (routeRuntime && runtimeIds.has(normalizeProviderId(routeRuntime))) { + return true; + } + return ( + params.route?.configuredModelRefs.some((ref) => { + const slash = ref.indexOf("/"); + return slash > 0 && providerIds.has(normalizeProviderId(ref.slice(0, slash))); + }) ?? false + ); +} + +function hasOwnedCliSession(params: { + entry: Record; + cliSessionKeys: readonly string[]; +}): boolean { + const bindings = params.entry.cliSessionBindings; + const ids = params.entry.cliSessionIds; + return params.cliSessionKeys.some((key) => { + const normalized = normalizeProviderId(key); + return ( + (bindings !== null && + typeof bindings === "object" && + normalized in bindings && + (bindings as Record)[normalized] !== undefined) || + (ids !== null && + typeof ids === "object" && + normalized in ids && + (ids as Record)[normalized] !== undefined) + ); + }); +} + +function modelRefKey(provider: string, model: string): string { + return modelKey(provider, model).toLowerCase(); +} + +function scanEntryForOwner(params: { + key: string; + entry: Record; + owner: DoctorSessionRouteStateOwner; + route: DoctorSessionRouteState | undefined; +}): { + repair?: DoctorSessionRouteStateRepair; + manualReview?: DoctorSessionRouteStateManualReview; +} { + const providerIds = normalizeIdSet(params.owner.providerIds); + const runtimeIds = normalizeIdSet(params.owner.runtimeIds); + const cliSessionKeys = [...normalizeIdSet(params.owner.cliSessionKeys)]; + const authProfilePrefixes = normalizePrefixList(params.owner.authProfilePrefixes); + const routeAllowsOwner = routeAllowsOwnerState({ owner: params.owner, route: params.route }); + const reasons: string[] = []; + const directOverride = resolvePersistedOverrideModelRef({ + defaultProvider: params.route?.defaultProvider ?? "", + overrideProvider: params.entry.providerOverride, + overrideModel: params.entry.modelOverride, + }); + const directOverrideKey = directOverride + ? modelRefKey(directOverride.provider, directOverride.model) + : undefined; + const directOverrideIsOwned = + directOverride !== null && providerIds.has(normalizeProviderId(directOverride.provider)); + const directOverrideIsConfigured = + directOverrideKey !== undefined && + (params.route?.configuredModelRefs.some((ref) => ref.toLowerCase() === directOverrideKey) ?? + false); + const directOverrideSource = + params.entry.modelOverrideSource === "user" + ? "user" + : params.entry.modelOverrideSource === "auto" + ? "auto" + : params.entry.modelOverride + ? "legacy" + : undefined; + + if (directOverrideIsOwned && !directOverrideIsConfigured) { + if (directOverrideSource === "auto") { + addReason(reasons, "auto model override"); + } else if (!routeAllowsOwner && directOverride) { + return { + manualReview: { + key: params.key, + ownerLabel: params.owner.label, + message: `${params.key} (${modelRefKey(directOverride.provider, directOverride.model)}, ${ + directOverrideSource === "user" ? "user" : "legacy" + })`, + }, + }; + } + } + + const explicitOwnedOverride = + directOverrideIsOwned && directOverrideSource !== undefined && directOverrideSource !== "auto"; + if (!routeAllowsOwner && !explicitOwnedOverride) { + const runtimeModel = normalizeString(params.entry.model); + const runtimeRef = runtimeModel + ? parseModelRef(runtimeModel, normalizeString(params.entry.modelProvider) ?? "", { + allowPluginNormalization: false, + }) + : null; + if (runtimeRef && providerIds.has(normalizeProviderId(runtimeRef.provider))) { + addReason(reasons, "runtime model state"); + } + const harnessId = normalizeString(params.entry.agentHarnessId); + if (harnessId && runtimeIds.has(normalizeProviderId(harnessId))) { + addReason(reasons, "pinned runtime"); + } + if (hasOwnedCliSession({ entry: params.entry, cliSessionKeys })) { + addReason(reasons, "CLI session binding"); + } + if ( + params.entry.authProfileOverrideSource === "auto" && + ownsPrefixedValue(authProfilePrefixes, params.entry.authProfileOverride) + ) { + addReason(reasons, "auto auth profile override"); + } + } + + if (reasons.length === 0) { + return {}; + } + return { + repair: { + key: params.key, + ownerId: params.owner.id, + ownerLabel: params.owner.label, + reasons, + cliSessionKeys, + }, + }; +} + +export function scanSessionRouteStateOwners(params: { + owners: readonly DoctorSessionRouteStateOwner[]; + store: Record>; + routes: Record; +}): DoctorSessionRouteStateScan { + const repairs: DoctorSessionRouteStateRepair[] = []; + const manualReview: DoctorSessionRouteStateManualReview[] = []; + for (const [key, entry] of Object.entries(params.store)) { + if (!entry || typeof entry !== "object") { + continue; + } + for (const owner of params.owners) { + const scan = scanEntryForOwner({ key, entry, owner, route: params.routes[key] }); + if (scan.repair) { + repairs.push(scan.repair); + } + if (scan.manualReview) { + manualReview.push(scan.manualReview); + } + } + } + return { repairs, manualReview }; +} + +function clearEntryKey(entry: Record, key: string): boolean { + if (entry[key] !== undefined) { + delete entry[key]; + return true; + } + return false; +} + +function clearRecordKeys( + entry: Record, + recordKey: string, + ownedKeys: readonly string[], +): boolean { + const value = entry[recordKey]; + if (value === null || typeof value !== "object") { + return false; + } + const record = value as Record; + let changed = false; + const next = { ...record }; + for (const key of ownedKeys) { + const normalized = normalizeProviderId(key); + if (next[normalized] !== undefined) { + delete next[normalized]; + changed = true; + } + } + if (!changed) { + return false; + } + entry[recordKey] = Object.keys(next).length > 0 ? next : undefined; + return true; +} + +export function applySessionRouteStateRepair(params: { + entry: Record; + repair: DoctorSessionRouteStateRepair; + now: number; +}): boolean { + let changed = false; + const clear = (key: string) => { + changed = clearEntryKey(params.entry, key) || changed; + }; + if (params.repair.reasons.includes("auto model override")) { + clear("providerOverride"); + clear("modelOverride"); + clear("modelOverrideSource"); + clear("liveModelSwitchPending"); + } + if (params.repair.reasons.includes("runtime model state")) { + clear("model"); + clear("modelProvider"); + clear("contextTokens"); + clear("systemPromptReport"); + clear("fallbackNoticeSelectedModel"); + clear("fallbackNoticeActiveModel"); + clear("fallbackNoticeReason"); + } + if (params.repair.reasons.includes("pinned runtime")) { + clear("agentHarnessId"); + } + if (params.repair.reasons.includes("CLI session binding")) { + changed = + clearRecordKeys(params.entry, "cliSessionBindings", params.repair.cliSessionKeys) || changed; + changed = + clearRecordKeys(params.entry, "cliSessionIds", params.repair.cliSessionKeys) || changed; + } + if (params.repair.reasons.includes("auto auth profile override")) { + clear("authProfileOverride"); + clear("authProfileOverrideSource"); + clear("authProfileOverrideCompactionCount"); + } + if (changed) { + params.entry.updatedAt = params.now; + } + return changed; +} + +function groupRepairsByOwner( + repairs: readonly DoctorSessionRouteStateRepair[], +): Map { + const grouped = new Map(); + for (const repair of repairs) { + const key = repair.ownerLabel; + grouped.set(key, [...(grouped.get(key) ?? []), repair]); + } + return grouped; +} + +export async function runPluginSessionStateDoctorRepairs(params: { + cfg: OpenClawConfig; + store: Record; + absoluteStorePath: string; + prompter: DoctorPrompterLike; + env?: NodeJS.ProcessEnv; + warnings: string[]; + changes: string[]; +}): Promise { + const owners = resolvePluginDoctorSessionRouteStateOwners({ env: params.env }); + if (owners.length === 0) { + return; + } + const routes = Object.fromEntries( + Object.keys(params.store).map((sessionKey) => [ + sessionKey, + resolveConfiguredDoctorSessionStateRoute({ cfg: params.cfg, sessionKey, env: params.env }), + ]), + ); + const store = params.store as unknown as Record>; + const scan = scanSessionRouteStateOwners({ owners, store, routes }); + if (scan.repairs.length > 0) { + for (const [ownerLabel, repairs] of groupRepairsByOwner(scan.repairs)) { + const staleCount = countSessionLabel(repairs.length); + params.warnings.push( + [ + `- Found stale ${ownerLabel} session routing state in ${staleCount} outside the current configured model/runtime route.`, + " This can keep later message-channel runs pinned to an old runtime/provider after defaults move elsewhere.", + ` Examples: ${repairs.slice(0, 3).map(repairExample).join(", ")}`, + ].join("\n"), + ); + const repairState = await params.prompter.confirmRuntimeRepair({ + message: `Clear stale ${ownerLabel} session routing state for ${staleCount}?`, + initialValue: true, + }); + if (repairState) { + let repaired = 0; + const repairedAt = Date.now(); + const repairsByKey = new Map(repairs.map((repair) => [repair.key, repair])); + await updateSessionStore(params.absoluteStorePath, (currentStore) => { + const currentMutableStore = currentStore as unknown as Record< + string, + Record + >; + for (const [key, repair] of repairsByKey) { + const current = currentMutableStore[key]; + if ( + current && + applySessionRouteStateRepair({ entry: current, repair, now: repairedAt }) + ) { + repaired += 1; + } + } + }); + if (repaired > 0) { + params.changes.push( + `- Cleared stale ${ownerLabel} session routing state for ${countSessionLabel( + repaired, + )}.`, + ); + } + } + } + } + if (scan.manualReview.length > 0) { + const grouped = new Map(); + for (const hit of scan.manualReview) { + grouped.set(hit.ownerLabel, [...(grouped.get(hit.ownerLabel) ?? []), hit]); + } + for (const [ownerLabel, hits] of grouped) { + params.warnings.push( + [ + `- Found explicit ${ownerLabel} model overrides in ${countSessionLabel( + hits.length, + )} outside the current configured route.`, + " Doctor leaves explicit or legacy user selections untouched; switch them with /model or reset the session if that provider is no longer intended.", + ` Examples: ${hits + .slice(0, 3) + .map((hit) => hit.message) + .join(", ")}`, + ].join("\n"), + ); + } + } +} diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index ed31eebaefa..dbcc907c5c9 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -32,6 +32,7 @@ import { asNullableObjectRecord } from "../shared/record-coerce.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; +import { runPluginSessionStateDoctorRepairs } from "./doctor-session-state-providers.js"; type DoctorPrompterLike = { confirmRuntimeRepair: (params: { @@ -920,6 +921,16 @@ export async function noteStateIntegrity( } } + await runPluginSessionStateDoctorRepairs({ + cfg, + store, + absoluteStorePath, + prompter, + env, + warnings, + changes, + }); + const mainKey = resolveMainSessionKey(cfg); const mainEntry = store[mainKey]; if (mainEntry?.sessionId) { diff --git a/src/plugin-sdk/runtime-doctor.ts b/src/plugin-sdk/runtime-doctor.ts index ae6e3df73fe..cde36bff507 100644 --- a/src/plugin-sdk/runtime-doctor.ts +++ b/src/plugin-sdk/runtime-doctor.ts @@ -17,3 +17,4 @@ export { formatPluginInstallPathIssue, } from "../infra/plugin-install-path-warnings.js"; export { removePluginFromConfig } from "../plugins/uninstall.js"; +export type { DoctorSessionRouteStateOwner } from "../plugins/doctor-session-route-state-owner-types.js"; diff --git a/src/plugins/doctor-contract-registry.test.ts b/src/plugins/doctor-contract-registry.test.ts index 100cbc39cd4..f4a5ade40d4 100644 --- a/src/plugins/doctor-contract-registry.test.ts +++ b/src/plugins/doctor-contract-registry.test.ts @@ -14,6 +14,7 @@ const mocks = getRegistryJitiMocks(); let clearPluginDoctorContractRegistryCache: typeof import("./doctor-contract-registry.js").clearPluginDoctorContractRegistryCache; let collectRelevantDoctorPluginIdsForTouchedPaths: typeof import("./doctor-contract-registry.js").collectRelevantDoctorPluginIdsForTouchedPaths; let listPluginDoctorLegacyConfigRules: typeof import("./doctor-contract-registry.js").listPluginDoctorLegacyConfigRules; +let listPluginDoctorSessionRouteStateOwners: typeof import("./doctor-contract-registry.js").listPluginDoctorSessionRouteStateOwners; function makeTempDir(): string { return makeTrackedTempDir("openclaw-doctor-contract-registry", tempDirs); @@ -31,6 +32,7 @@ describe("doctor-contract-registry module loader", () => { clearPluginDoctorContractRegistryCache, collectRelevantDoctorPluginIdsForTouchedPaths, listPluginDoctorLegacyConfigRules, + listPluginDoctorSessionRouteStateOwners, } = await import("./doctor-contract-registry.js")); clearPluginDoctorContractRegistryCache(); }); @@ -183,6 +185,35 @@ describe("doctor-contract-registry module loader", () => { } }); + it("loads session route-state owners from doctor contract modules", () => { + const pluginRoot = makeTempDir(); + fs.writeFileSync( + path.join(pluginRoot, "doctor-contract-api.cjs"), + "module.exports = { sessionRouteStateOwners: [{ id: 'demo', label: 'Demo', providerIds: ['demo'], runtimeIds: ['demo-cli'], cliSessionKeys: ['demo-cli'], authProfilePrefixes: ['demo:'] }] };\n", + "utf-8", + ); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [{ id: "test-plugin", rootDir: pluginRoot }], + diagnostics: [], + }); + + expect( + listPluginDoctorSessionRouteStateOwners({ + workspaceDir: pluginRoot, + env: {}, + }), + ).toEqual([ + { + id: "demo", + label: "Demo", + providerIds: ["demo"], + runtimeIds: ["demo-cli"], + cliSessionKeys: ["demo-cli"], + authProfilePrefixes: ["demo:"], + }, + ]); + }); + it("reads doctor contracts from the current manifest registry on each call", () => { const firstRoot = makeTempDir(); const secondRoot = makeTempDir(); diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index c182e1cf973..26d36d6a097 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import type { LegacyConfigRule } from "../config/legacy.shared.js"; import type { OpenClawConfig } from "../config/types.js"; import { asNullableRecord } from "../shared/record-coerce.js"; +import type { DoctorSessionRouteStateOwner } from "./doctor-session-route-state-owner-types.js"; import type { PluginManifestRegistry } from "./manifest-registry.js"; import { createPluginModuleLoaderCache, @@ -21,6 +22,7 @@ const RUNNING_FROM_BUILT_ARTIFACT = type PluginDoctorContractModule = { legacyConfigRules?: unknown; normalizeCompatibilityConfig?: unknown; + sessionRouteStateOwners?: unknown; }; type PluginDoctorCompatibilityMutation = { @@ -36,6 +38,7 @@ type PluginDoctorContractEntry = { pluginId: string; rules: LegacyConfigRule[]; normalizeCompatibilityConfig?: PluginDoctorCompatibilityNormalizer; + sessionRouteStateOwners: DoctorSessionRouteStateOwner[]; }; type PluginManifestRegistryRecord = PluginManifestRegistry["plugins"][number]; @@ -88,6 +91,57 @@ function coerceNormalizeCompatibilityConfig( return typeof value === "function" ? (value as PluginDoctorCompatibilityNormalizer) : undefined; } +function normalizeTrimmedStringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()); +} + +function isDoctorSessionRouteStateOwner(value: unknown): value is DoctorSessionRouteStateOwner { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as { + id?: unknown; + label?: unknown; + providerIds?: unknown; + runtimeIds?: unknown; + cliSessionKeys?: unknown; + authProfilePrefixes?: unknown; + }; + return ( + typeof candidate.id === "string" && + typeof candidate.label === "string" && + candidate.id.trim().length > 0 && + candidate.label.trim().length > 0 && + (candidate.providerIds === undefined || + normalizeTrimmedStringList(candidate.providerIds).length > 0) && + (candidate.runtimeIds === undefined || + normalizeTrimmedStringList(candidate.runtimeIds).length > 0) && + (candidate.cliSessionKeys === undefined || + normalizeTrimmedStringList(candidate.cliSessionKeys).length > 0) && + (candidate.authProfilePrefixes === undefined || + normalizeTrimmedStringList(candidate.authProfilePrefixes).length > 0) + ); +} + +function coerceDoctorSessionRouteStateOwners(value: unknown): DoctorSessionRouteStateOwner[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter(isDoctorSessionRouteStateOwner).map((owner) => ({ + id: owner.id.trim(), + label: owner.label.trim(), + providerIds: normalizeTrimmedStringList(owner.providerIds), + runtimeIds: normalizeTrimmedStringList(owner.runtimeIds), + cliSessionKeys: normalizeTrimmedStringList(owner.cliSessionKeys), + authProfilePrefixes: normalizeTrimmedStringList(owner.authProfilePrefixes), + })); +} + function hasLegacyElevenLabsTalkFields(raw: unknown): boolean { const talk = asNullableRecord(asNullableRecord(raw)?.talk); if (!talk) { @@ -185,13 +239,18 @@ function loadPluginDoctorContractEntry( mod.normalizeCompatibilityConfig ?? (mod as { default?: PluginDoctorContractModule }).default?.normalizeCompatibilityConfig, ); - if (rules.length === 0 && !normalizeCompatibilityConfig) { + const sessionRouteStateOwners = coerceDoctorSessionRouteStateOwners( + mod.sessionRouteStateOwners ?? + (mod as { default?: PluginDoctorContractModule }).default?.sessionRouteStateOwners, + ); + if (rules.length === 0 && !normalizeCompatibilityConfig && sessionRouteStateOwners.length === 0) { return null; } return { pluginId: record.id, rules, normalizeCompatibilityConfig, + sessionRouteStateOwners, }; } @@ -243,6 +302,22 @@ export function listPluginDoctorLegacyConfigRules(params?: { return resolvePluginDoctorContracts(params).flatMap((entry) => entry.rules); } +export function listPluginDoctorSessionRouteStateOwners(params?: { + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; +}): DoctorSessionRouteStateOwner[] { + const owners = new Map(); + for (const owner of resolvePluginDoctorContracts(params).flatMap( + (entry) => entry.sessionRouteStateOwners, + )) { + if (!owners.has(owner.id)) { + owners.set(owner.id, owner); + } + } + return [...owners.values()].toSorted((left, right) => left.id.localeCompare(right.id)); +} + export function applyPluginDoctorCompatibilityMigrations( cfg: OpenClawConfig, params?: { diff --git a/src/plugins/doctor-session-route-state-owner-types.ts b/src/plugins/doctor-session-route-state-owner-types.ts new file mode 100644 index 00000000000..c8fa0d2d0f6 --- /dev/null +++ b/src/plugins/doctor-session-route-state-owner-types.ts @@ -0,0 +1,8 @@ +export type DoctorSessionRouteStateOwner = { + id: string; + label: string; + providerIds?: readonly string[]; + runtimeIds?: readonly string[]; + cliSessionKeys?: readonly string[]; + authProfilePrefixes?: readonly string[]; +};