diff --git a/CHANGELOG.md b/CHANGELOG.md index f773b92da09..5b775dca202 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Memory-core: re-resolve the active runtime config whenever `memory_search` or `memory_get` executes, so provider changes made by `config.patch` stop leaving stale embedding backends behind in existing tool instances. Fixes #61098. Thanks @BradGroux and @Linux2010. - WebChat: keep bare `/new` and `/reset` startup instructions out of visible chat history while preserving `/reset ` as user-visible transcript text. Fixes #72369. Thanks @collynes and @haishmg. +- CLI/doctor: remove dangling channel config, heartbeat targets, and channel model overrides when stale plugin repair removes a missing channel plugin, preventing Gateway boot loops after failed plugin reinstalls. Fixes #65293. Thanks @yidecode. - Channels/setup: treat bundled channel plugins as already bundled during `channels add` and onboarding, enabling them without writing redundant `plugins.load.paths` entries or path install records. Fixes #72740. Thanks @iCodePoet. - WhatsApp: honor gateway `HTTPS_PROXY` / `HTTP_PROXY` env vars for QR-login WebSocket connections, while respecting `NO_PROXY`, so proxied networks no longer fall back to direct `mmg.whatsapp.net` connections that time out with 408. Fixes #72547; supersedes #72692. Thanks @mebusw and @SymbolStar. - Bonjour: default mDNS advertisements to the system hostname when it is DNS-safe, avoiding `openclaw.local` probing conflicts and Gateway restart loops on hosts such as `Lobster` or `ubuntu`. Fixes #72355 and #72689; supersedes #72694. Thanks @mscheuerlein-bot, @gcusms, @moyuwuhen601, @pavan987, @zml-0912, @hhq365, and @SymbolStar. diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index f622d6bc30f..0ba56f83f3f 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -44,6 +44,7 @@ Notes: - State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.` to reclaim space safely. - Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime. - Doctor repairs missing bundled plugin runtime dependencies without writing into packaged global installs. For root-owned npm installs or hardened systemd units, set `OPENCLAW_PLUGIN_STAGE_DIR` to a writable directory such as `/var/lib/openclaw/plugin-runtime-deps`; it can also be a path-list such as `/opt/openclaw/plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps`, where earlier roots are read-only lookup layers and the final root is the repair target. +- Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy. - Set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when another supervisor owns the gateway lifecycle. Doctor still reports gateway/service health and applies non-service repairs, but skips service install/start/restart/bootstrap and legacy service cleanup. - Doctor auto-migrates legacy flat Talk config (`talk.voiceId`, `talk.modelId`, and friends) into `talk.provider` + `talk.providers.`. - Repeat `doctor --fix` runs no longer report/apply Talk normalization when the only difference is object key order. diff --git a/src/commands/doctor/shared/preview-warnings.test.ts b/src/commands/doctor/shared/preview-warnings.test.ts index 9bf5ba88b1a..b60510bbdd8 100644 --- a/src/commands/doctor/shared/preview-warnings.test.ts +++ b/src/commands/doctor/shared/preview-warnings.test.ts @@ -99,10 +99,18 @@ vi.mock("./channel-plugin-blockers.js", () => ({ vi.mock("./stale-plugin-config.js", () => ({ scanStalePluginConfig: (cfg: { plugins?: { allow?: string[]; entries?: Record }; + channels?: Record; }) => { const knownIds = new Set(manifestState.plugins.map((plugin) => plugin.id)); - const ids = [...(cfg.plugins?.allow ?? []), ...Object.keys(cfg.plugins?.entries ?? {})]; - return [...new Set(ids)].filter((id) => !knownIds.has(id)).map((id) => ({ id })); + const hits = [...(cfg.plugins?.allow ?? []), ...Object.keys(cfg.plugins?.entries ?? {})] + .filter((id) => !knownIds.has(id)) + .map((id) => ({ id, surface: "plugin" })); + if (cfg.channels?.["openclaw-weixin"]) { + hits.push({ id: "openclaw-weixin", surface: "channel" }); + } + return hits.filter( + (hit, index) => hits.findIndex((candidate) => candidate.id === hit.id) === index, + ); }, isStalePluginAutoRepairBlocked: () => manifestState.diagnostics.some((diagnostic) => diagnostic.level === "error"), @@ -113,16 +121,19 @@ vi.mock("./stale-plugin-config.js", () => ({ }: { autoRepairBlocked: boolean; doctorFixCommand: string; - hits: Array<{ id: string }>; + hits: Array<{ id: string; surface: string }>; }) => - hits.map( - (hit) => - `plugins.allow: stale plugin reference "${hit.id}". plugins.entries.${hit.id} is unused. ${ - autoRepairBlocked - ? `Auto-removal is paused; rerun "${doctorFixCommand}".` - : `Run "${doctorFixCommand}".` - }`, - ), + hits.map((hit) => { + const prefix = + hit.surface === "channel" + ? `channels.${hit.id}: dangling channel config.` + : `plugins.allow: stale plugin reference "${hit.id}". plugins.entries.${hit.id} is unused.`; + return `${prefix} ${ + autoRepairBlocked + ? `Auto-removal is paused; rerun "${doctorFixCommand}".` + : `Run "${doctorFixCommand}".` + }`; + }), })); vi.mock("./bundled-plugin-load-paths.js", () => ({ @@ -239,6 +250,23 @@ describe("doctor preview warnings", () => { expect(warnings[0]).not.toContain("Auto-removal is paused"); }); + it("includes stale channel config warnings without plugin config", async () => { + const warnings = await collectDoctorPreviewWarnings({ + cfg: { + channels: { + "openclaw-weixin": { + enabled: true, + }, + }, + }, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(warnings).toEqual([ + expect.stringContaining("channels.openclaw-weixin: dangling channel config"), + ]); + }); + it("includes bundled plugin load path migration warnings", async () => { const packageRoot = path.resolve("app-node-modules", "openclaw"); const legacyPath = path.join(packageRoot, "extensions", "feishu"); diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index 7329a548607..cb34be2ecb6 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -123,7 +123,7 @@ export async function collectDoctorPreviewWarnings(params: { } } - if (hasPluginConfig) { + if (hasPluginConfig || hasChannelConfig) { const { collectStalePluginConfigWarnings, isStalePluginAutoRepairBlocked, @@ -139,7 +139,9 @@ export async function collectDoctorPreviewWarnings(params: { }).join("\n"), ); } + } + if (hasPluginConfig) { const { collectCodexRouteWarnings } = await import("./codex-route-warnings.js"); warnings.push(...collectCodexRouteWarnings({ cfg: params.cfg, env })); } diff --git a/src/commands/doctor/shared/stale-plugin-config.test.ts b/src/commands/doctor/shared/stale-plugin-config.test.ts index 5ebc4d63490..0bfedc17537 100644 --- a/src/commands/doctor/shared/stale-plugin-config.test.ts +++ b/src/commands/doctor/shared/stale-plugin-config.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; +import type { PluginInstallRecord } from "../../../config/types.plugins.js"; import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js"; import * as manifestRegistry from "../../../plugins/manifest-registry.js"; import { @@ -8,6 +9,18 @@ import { scanStalePluginConfig, } from "./stale-plugin-config.js"; +const installedPluginIndexMocks = vi.hoisted(() => ({ + loadInstalledPluginIndexInstallRecordsSync: vi.fn<() => Record>( + () => ({}), + ), +})); + +vi.mock("../../../plugins/installed-plugin-index-records.js", async (importOriginal) => ({ + ...(await importOriginal()), + loadInstalledPluginIndexInstallRecordsSync: + installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync, +})); + function manifest(id: string): PluginManifestRecord { return { id, @@ -25,6 +38,8 @@ function manifest(id: string): PluginManifestRecord { describe("doctor stale plugin config helpers", () => { beforeEach(() => { + installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync.mockReset(); + installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync.mockReturnValue({}); vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ plugins: [manifest("discord"), manifest("voice-call"), manifest("openai")], diagnostics: [], @@ -99,6 +114,113 @@ describe("doctor stale plugin config helpers", () => { ]); }); + it("removes stale third-party channel config and dependent channel refs", () => { + const result = maybeRepairStalePluginConfig({ + plugins: { + allow: ["discord", "openclaw-weixin"], + entries: { + discord: { enabled: true }, + "openclaw-weixin": { enabled: true }, + }, + }, + channels: { + "openclaw-weixin": { + enabled: true, + token: "stale", + }, + telegram: { + botToken: "keep", + }, + modelByChannel: { + openai: { + "openclaw-weixin": "openai/gpt-5.4", + telegram: "openai/gpt-5.4", + }, + }, + }, + agents: { + defaults: { + heartbeat: { + target: "openclaw-weixin", + every: "30m", + }, + }, + list: [ + { + id: "pi", + heartbeat: { + target: "openclaw-weixin", + }, + }, + { + id: "ops", + heartbeat: { + target: "telegram", + }, + }, + ], + }, + } as OpenClawConfig); + + expect(result.changes).toEqual([ + "- plugins.allow: removed 1 stale plugin id (openclaw-weixin)", + "- plugins.entries: removed 1 stale plugin entry (openclaw-weixin)", + "- channels: removed 1 stale channel config (openclaw-weixin)", + "- agents heartbeat: removed 2 stale heartbeat targets (openclaw-weixin)", + "- channels.modelByChannel: removed 1 stale channel model override (openclaw-weixin)", + ]); + expect(result.config.plugins?.allow).toEqual(["discord"]); + expect(result.config.plugins?.entries).toEqual({ + discord: { enabled: true }, + }); + expect(result.config.channels?.["openclaw-weixin"]).toBeUndefined(); + expect(result.config.channels?.telegram).toEqual({ botToken: "keep" }); + expect(result.config.channels?.modelByChannel).toEqual({ + openai: { + telegram: "openai/gpt-5.4", + }, + }); + expect(result.config.agents?.defaults?.heartbeat).toEqual({ every: "30m" }); + expect(result.config.agents?.list?.[0]?.heartbeat).toEqual({}); + expect(result.config.agents?.list?.[1]?.heartbeat).toEqual({ target: "telegram" }); + }); + + it("does not remove unknown channel config without stale plugin evidence", () => { + const cfg = { + channels: { + telegrm: { + botToken: "typo", + }, + }, + } as OpenClawConfig; + + expect(scanStalePluginConfig(cfg)).toEqual([]); + expect(maybeRepairStalePluginConfig(cfg)).toEqual({ config: cfg, changes: [] }); + }); + + it("uses missing persisted install records as stale channel evidence", () => { + installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync.mockReturnValue({ + "openclaw-weixin": { + source: "npm", + resolvedName: "@tencent-weixin/openclaw-weixin", + installedAt: "2026-04-12T00:00:00.000Z", + }, + }); + + const result = maybeRepairStalePluginConfig({ + channels: { + "openclaw-weixin": { + enabled: true, + }, + }, + } as OpenClawConfig); + + expect(result.changes).toEqual([ + "- channels: removed 1 stale channel config (openclaw-weixin)", + ]); + expect(result.config.channels?.["openclaw-weixin"]).toBeUndefined(); + }); + it("does not auto-repair stale refs while plugin discovery has errors", () => { vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ plugins: [], diff --git a/src/commands/doctor/shared/stale-plugin-config.ts b/src/commands/doctor/shared/stale-plugin-config.ts index 23da42aaaf6..7414302d76e 100644 --- a/src/commands/doctor/shared/stale-plugin-config.ts +++ b/src/commands/doctor/shared/stale-plugin-config.ts @@ -1,11 +1,15 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js"; +import { CHANNEL_IDS } from "../../../channels/ids.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { normalizePluginId } from "../../../plugins/config-state.js"; +import { loadInstalledPluginIndexInstallRecordsSync } from "../../../plugins/installed-plugin-index-records.js"; import { loadPluginManifestRegistryForPluginRegistry } from "../../../plugins/plugin-registry.js"; import { sanitizeForLog } from "../../../terminal/ansi.js"; import { asObjectRecord } from "./object.js"; -type StalePluginSurface = "allow" | "entries"; +const CHANNEL_CONFIG_META_KEYS = new Set(["defaults", "modelByChannel"]); + +type StalePluginSurface = "allow" | "entries" | "channel" | "heartbeat" | "modelByChannel"; type StalePluginConfigHit = { pluginId: string; @@ -15,6 +19,8 @@ type StalePluginConfigHit = { type StalePluginRegistryState = { knownIds: Set; + knownChannelIds: Set; + missingInstalledIds: Set; hasDiscoveryErrors: boolean; }; @@ -29,8 +35,37 @@ function collectPluginRegistryState( env, includeDisabled: true, }); + const knownIds = new Set(registry.plugins.map((plugin) => plugin.id)); + const installedIds = new Set(); + for (const pluginId of Object.keys(cfg.plugins?.installs ?? {})) { + const normalized = normalizePluginId(pluginId); + if (normalized) { + installedIds.add(normalized); + } + } + try { + for (const pluginId of Object.keys(loadInstalledPluginIndexInstallRecordsSync({ env }))) { + const normalized = normalizePluginId(pluginId); + if (normalized) { + installedIds.add(normalized); + } + } + } catch { + // Missing/corrupt install-record state must not block normal doctor scans. + } + const knownChannelIds = new Set(CHANNEL_IDS.map((channelId) => normalizePluginId(channelId))); + for (const plugin of registry.plugins) { + for (const channelId of plugin.channels) { + const normalized = normalizePluginId(channelId); + if (normalized) { + knownChannelIds.add(normalized); + } + } + } return { - knownIds: new Set(registry.plugins.map((plugin) => plugin.id)), + knownIds, + knownChannelIds, + missingInstalledIds: new Set([...installedIds].filter((pluginId) => !knownIds.has(pluginId))), hasDiscoveryErrors: registry.diagnostics.some((diag) => diag.level === "error"), }; } @@ -46,22 +81,19 @@ export function scanStalePluginConfig( cfg: OpenClawConfig, env?: NodeJS.ProcessEnv, ): StalePluginConfigHit[] { - const plugins = asObjectRecord(cfg.plugins); - if (!plugins) { - return []; - } - - return scanStalePluginConfigWithState(plugins, collectPluginRegistryState(cfg, env)); + return scanStalePluginConfigWithState(cfg, collectPluginRegistryState(cfg, env)); } function scanStalePluginConfigWithState( - plugins: Record, + cfg: OpenClawConfig, registryState: StalePluginRegistryState, ): StalePluginConfigHit[] { + const plugins = asObjectRecord(cfg.plugins); const { knownIds } = registryState; const hits: StalePluginConfigHit[] = []; + const staleEvidenceIds = new Set(registryState.missingInstalledIds); - const allow = Array.isArray(plugins.allow) ? plugins.allow : []; + const allow = Array.isArray(plugins?.allow) ? plugins.allow : []; for (const rawPluginId of allow) { if (typeof rawPluginId !== "string") { continue; @@ -75,26 +107,139 @@ function scanStalePluginConfigWithState( pathLabel: "plugins.allow", surface: "allow", }); + staleEvidenceIds.add(pluginId); } - const entries = asObjectRecord(plugins.entries); - if (!entries) { - return hits; - } - for (const rawPluginId of Object.keys(entries)) { - if (knownIds.has(normalizePluginId(rawPluginId))) { - continue; + const entries = asObjectRecord(plugins?.entries); + if (entries) { + for (const rawPluginId of Object.keys(entries)) { + const pluginId = normalizePluginId(rawPluginId); + if (!pluginId || knownIds.has(pluginId)) { + continue; + } + hits.push({ + pluginId: rawPluginId, + pathLabel: `plugins.entries.${rawPluginId}`, + surface: "entries", + }); + staleEvidenceIds.add(pluginId); } + } + + const staleChannelIds = collectDanglingChannelIds({ + cfg, + registryState, + staleEvidenceIds, + }); + for (const channelId of staleChannelIds) { hits.push({ - pluginId: rawPluginId, - pathLabel: `plugins.entries.${rawPluginId}`, - surface: "entries", + pluginId: channelId, + pathLabel: `channels.${channelId}`, + surface: "channel", }); } + for (const hit of collectDependentChannelConfigHits(cfg, staleChannelIds)) { + hits.push(hit); + } return hits; } +function collectDanglingChannelIds(params: { + cfg: OpenClawConfig; + registryState: StalePluginRegistryState; + staleEvidenceIds: ReadonlySet; +}): string[] { + const channels = asObjectRecord(params.cfg.channels); + if (!channels) { + return []; + } + const ids: string[] = []; + const seen = new Set(); + for (const channelId of Object.keys(channels)) { + if (CHANNEL_CONFIG_META_KEYS.has(channelId)) { + continue; + } + const normalized = normalizePluginId(channelId); + if ( + !normalized || + params.registryState.knownChannelIds.has(normalized) || + !params.staleEvidenceIds.has(normalized) || + seen.has(normalized) + ) { + continue; + } + seen.add(normalized); + ids.push(channelId); + } + return ids; +} + +function collectDependentChannelConfigHits( + cfg: OpenClawConfig, + channelIds: readonly string[], +): StalePluginConfigHit[] { + if (channelIds.length === 0) { + return []; + } + const staleChannelIds = new Set(channelIds.map((channelId) => normalizePluginId(channelId))); + const hits: StalePluginConfigHit[] = []; + const defaultTarget = cfg.agents?.defaults?.heartbeat?.target; + if (typeof defaultTarget === "string" && staleChannelIds.has(normalizePluginId(defaultTarget))) { + hits.push({ + pluginId: defaultTarget, + pathLabel: "agents.defaults.heartbeat.target", + surface: "heartbeat", + }); + } + for (const [index, agent] of (cfg.agents?.list ?? []).entries()) { + const target = agent?.heartbeat?.target; + if (typeof target !== "string" || !staleChannelIds.has(normalizePluginId(target))) { + continue; + } + hits.push({ + pluginId: target, + pathLabel: `agents.list.${index}.heartbeat.target`, + surface: "heartbeat", + }); + } + + const modelByChannel = asObjectRecord(cfg.channels?.modelByChannel); + if (modelByChannel) { + for (const [providerId, channelMap] of Object.entries(modelByChannel)) { + const channels = asObjectRecord(channelMap); + if (!channels) { + continue; + } + for (const channelId of Object.keys(channels)) { + if (!staleChannelIds.has(normalizePluginId(channelId))) { + continue; + } + hits.push({ + pluginId: channelId, + pathLabel: `channels.modelByChannel.${providerId}.${channelId}`, + surface: "modelByChannel", + }); + } + } + } + + return hits; +} + +function formatStalePluginHitWarning(hit: StalePluginConfigHit): string { + if (hit.surface === "allow" || hit.surface === "entries") { + return `- ${hit.pathLabel}: stale plugin reference "${hit.pluginId}" was found.`; + } + if (hit.surface === "channel") { + return `- ${hit.pathLabel}: dangling channel config for missing plugin "${hit.pluginId}" was found.`; + } + if (hit.surface === "heartbeat") { + return `- ${hit.pathLabel}: heartbeat target references missing channel plugin "${hit.pluginId}".`; + } + return `- ${hit.pathLabel}: model override references missing channel plugin "${hit.pluginId}".`; +} + export function collectStalePluginConfigWarnings(params: { hits: StalePluginConfigHit[]; doctorFixCommand: string; @@ -103,16 +248,14 @@ export function collectStalePluginConfigWarnings(params: { if (params.hits.length === 0) { return []; } - const lines = params.hits.map( - (hit) => `- ${hit.pathLabel}: stale plugin reference "${hit.pluginId}" was found.`, - ); + const lines = params.hits.map((hit) => formatStalePluginHitWarning(hit)); if (params.autoRepairBlocked) { lines.push( `- Auto-removal is paused because plugin discovery currently has errors. Fix plugin discovery first, then rerun "${params.doctorFixCommand}".`, ); } else { lines.push( - `- Run "${params.doctorFixCommand}" to remove stale plugins.allow and plugins.entries ids.`, + `- Run "${params.doctorFixCommand}" to remove stale plugin ids and dangling channel references.`, ); } return lines.map((line) => sanitizeForLog(line)); @@ -125,29 +268,21 @@ export function maybeRepairStalePluginConfig( config: OpenClawConfig; changes: string[]; } { - const plugins = asObjectRecord(cfg.plugins); - if (!plugins) { - return { config: cfg, changes: [] }; - } - const registryState = collectPluginRegistryState(cfg, env); if (registryState.hasDiscoveryErrors) { return { config: cfg, changes: [] }; } - const hits = scanStalePluginConfigWithState(plugins, registryState); + const hits = scanStalePluginConfigWithState(cfg, registryState); if (hits.length === 0) { return { config: cfg, changes: [] }; } const next = structuredClone(cfg); const nextPlugins = asObjectRecord(next.plugins); - if (!nextPlugins) { - return { config: cfg, changes: [] }; - } const allowIds = hits.filter((hit) => hit.surface === "allow").map((hit) => hit.pluginId); - if (allowIds.length > 0 && Array.isArray(nextPlugins.allow)) { + if (allowIds.length > 0 && Array.isArray(nextPlugins?.allow)) { const staleAllowIds = new Set(allowIds.map((pluginId) => normalizePluginId(pluginId))); nextPlugins.allow = nextPlugins.allow.filter( (pluginId) => typeof pluginId !== "string" || !staleAllowIds.has(normalizePluginId(pluginId)), @@ -156,7 +291,7 @@ export function maybeRepairStalePluginConfig( const entryIds = hits.filter((hit) => hit.surface === "entries").map((hit) => hit.pluginId); if (entryIds.length > 0) { - const entries = asObjectRecord(nextPlugins.entries); + const entries = asObjectRecord(nextPlugins?.entries); if (entries) { const staleEntryIds = new Set(entryIds.map((pluginId) => normalizePluginId(pluginId))); for (const pluginId of Object.keys(entries)) { @@ -167,6 +302,11 @@ export function maybeRepairStalePluginConfig( } } + const channelIds = hits.filter((hit) => hit.surface === "channel").map((hit) => hit.pluginId); + if (channelIds.length > 0) { + removeDanglingChannelReferences(next, channelIds); + } + const changes: string[] = []; if (allowIds.length > 0) { changes.push( @@ -178,6 +318,78 @@ export function maybeRepairStalePluginConfig( `- plugins.entries: removed ${entryIds.length} stale plugin entr${entryIds.length === 1 ? "y" : "ies"} (${entryIds.join(", ")})`, ); } + if (channelIds.length > 0) { + changes.push( + `- channels: removed ${channelIds.length} stale channel config${channelIds.length === 1 ? "" : "s"} (${channelIds.join(", ")})`, + ); + const heartbeatCount = hits.filter((hit) => hit.surface === "heartbeat").length; + if (heartbeatCount > 0) { + changes.push( + `- agents heartbeat: removed ${heartbeatCount} stale heartbeat target${heartbeatCount === 1 ? "" : "s"} (${channelIds.join(", ")})`, + ); + } + const modelByChannelCount = hits.filter((hit) => hit.surface === "modelByChannel").length; + if (modelByChannelCount > 0) { + changes.push( + `- channels.modelByChannel: removed ${modelByChannelCount} stale channel model override${modelByChannelCount === 1 ? "" : "s"} (${channelIds.join(", ")})`, + ); + } + } return { config: next, changes }; } + +function removeDanglingChannelReferences(config: OpenClawConfig, channelIds: readonly string[]) { + const staleChannelIds = new Set(channelIds.map((channelId) => normalizePluginId(channelId))); + const channels = asObjectRecord(config.channels); + if (channels) { + for (const channelId of Object.keys(channels)) { + if (CHANNEL_CONFIG_META_KEYS.has(channelId)) { + continue; + } + if (staleChannelIds.has(normalizePluginId(channelId))) { + delete channels[channelId]; + } + } + + const modelByChannel = asObjectRecord(channels.modelByChannel); + if (modelByChannel) { + for (const [providerId, channelMap] of Object.entries(modelByChannel)) { + const channelsForProvider = asObjectRecord(channelMap); + if (!channelsForProvider) { + continue; + } + for (const channelId of Object.keys(channelsForProvider)) { + if (staleChannelIds.has(normalizePluginId(channelId))) { + delete channelsForProvider[channelId]; + } + } + if (Object.keys(channelsForProvider).length === 0) { + delete modelByChannel[providerId]; + } + } + if (Object.keys(modelByChannel).length === 0) { + delete channels.modelByChannel; + } + } + } + + const defaultsHeartbeat = config.agents?.defaults?.heartbeat; + if ( + defaultsHeartbeat && + typeof defaultsHeartbeat.target === "string" && + staleChannelIds.has(normalizePluginId(defaultsHeartbeat.target)) + ) { + delete defaultsHeartbeat.target; + } + for (const agent of config.agents?.list ?? []) { + const heartbeat = agent.heartbeat; + if ( + heartbeat && + typeof heartbeat.target === "string" && + staleChannelIds.has(normalizePluginId(heartbeat.target)) + ) { + delete heartbeat.target; + } + } +}