mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix: repair stale session route state in doctor
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
12
extensions/codex/doctor-contract-api.ts
Normal file
12
extensions/codex/doctor-contract-api.ts
Normal 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:"],
|
||||
},
|
||||
];
|
||||
236
src/commands/doctor-session-state-providers.test.ts
Normal file
236
src/commands/doctor-session-state-providers.test.ts
Normal 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: [] });
|
||||
});
|
||||
});
|
||||
512
src/commands/doctor-session-state-providers.ts
Normal file
512
src/commands/doctor-session-state-providers.ts
Normal 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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
8
src/plugins/doctor-session-route-state-owner-types.ts
Normal file
8
src/plugins/doctor-session-route-state-owner-types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type DoctorSessionRouteStateOwner = {
|
||||
id: string;
|
||||
label: string;
|
||||
providerIds?: readonly string[];
|
||||
runtimeIds?: readonly string[];
|
||||
cliSessionKeys?: readonly string[];
|
||||
authProfilePrefixes?: readonly string[];
|
||||
};
|
||||
Reference in New Issue
Block a user