fix: repair stale session route state in doctor

This commit is contained in:
Peter Steinberger
2026-05-05 01:03:58 +01:00
parent e2e0908055
commit b17bb63b9e
10 changed files with 894 additions and 1 deletions

View File

@@ -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 <provider/model> --runtime <runtime>`. Thanks @vincentkoc.
- Doctor/config: keep active `auth.profiles` metadata intact when `doctor --fix` strips stale secret fields from configs, repairing legacy `<provider>: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.

View File

@@ -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.
</Accordion>
<Accordion title="2g. Session route cleanup">
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.
</Accordion>
<Accordion title="3. Legacy state migrations (disk layout)">
Doctor can migrate older on-disk layouts into the current structure:

View File

@@ -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:"],
},
];

View File

@@ -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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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: [] });
});
});

View File

@@ -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<boolean>;
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<string> {
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<string>();
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<string, unknown>;
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<string, unknown>)[normalized] !== undefined) ||
(ids !== null &&
typeof ids === "object" &&
normalized in ids &&
(ids as Record<string, unknown>)[normalized] !== undefined)
);
});
}
function modelRefKey(provider: string, model: string): string {
return modelKey(provider, model).toLowerCase();
}
function scanEntryForOwner(params: {
key: string;
entry: Record<string, unknown>;
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<string, Record<string, unknown>>;
routes: Record<string, DoctorSessionRouteState>;
}): 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<string, unknown>, key: string): boolean {
if (entry[key] !== undefined) {
delete entry[key];
return true;
}
return false;
}
function clearRecordKeys(
entry: Record<string, unknown>,
recordKey: string,
ownedKeys: readonly string[],
): boolean {
const value = entry[recordKey];
if (value === null || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
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<string, unknown>;
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<string, DoctorSessionRouteStateRepair[]> {
const grouped = new Map<string, DoctorSessionRouteStateRepair[]>();
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<string, SessionEntry>;
absoluteStorePath: string;
prompter: DoctorPrompterLike;
env?: NodeJS.ProcessEnv;
warnings: string[];
changes: string[];
}): Promise<void> {
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<string, Record<string, unknown>>;
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<string, unknown>
>;
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<string, DoctorSessionRouteStateManualReview[]>();
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"),
);
}
}
}

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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();

View File

@@ -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<string, DoctorSessionRouteStateOwner>();
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?: {

View File

@@ -0,0 +1,8 @@
export type DoctorSessionRouteStateOwner = {
id: string;
label: string;
providerIds?: readonly string[];
runtimeIds?: readonly string[];
cliSessionKeys?: readonly string[];
authProfilePrefixes?: readonly string[];
};