From fe15268e5f1e20a1f6d3b14901626f989585c98a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 02:24:54 +0100 Subject: [PATCH] fix: degrade plugin-local reload invalidity --- CHANGELOG.md | 1 + src/gateway/config-reload.test.ts | 39 ++++++++++++++++++-- src/gateway/config-reload.ts | 60 ++++++++++++++++++++++++++++--- 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4456d0610..be1e8040463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Cron/providers: preflight local Ollama and OpenAI-compatible provider endpoints before isolated cron agent turns, record unreachable local providers as skipped runs, and cache dead-endpoint probes so many jobs do not hammer the same stopped local server. Fixes #58584. Thanks @jpeghead. +- Gateway/config: let config reload continue in degraded mode when invalidity is scoped to plugin entries, so incompatible plugin configs can be skipped and the Gateway restart can still pick up the rest of the config after rollbacks. Fixes #73131. Thanks @Adam-Researchh. - Doctor/channels: suppress disabled bundled-plugin blocker warnings when a trusted external plugin owns the configured channel, so Lark/Feishu installs no longer get Feishu repair noise after switching to `openclaw-lark`. Fixes #56794. Thanks @wuji-tech-dev. - CLI/status: show skipped fast-path memory checks as `not checked` and report active custom memory plugin runtime status from `status --json --all` without requiring built-in `agents.defaults.memorySearch`, so plugins such as memory-lancedb-pro and memory-cms no longer look unavailable when their own runtime is healthy. Fixes #56968. Thanks @Tony-ooo and @aderius. - Gateway/channels: record and log unexpected clean channel monitor exits so channels that return without throwing no longer appear stopped with no error. Fixes #73099. Thanks @balaji1968-kingler. diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index a1bec2ff86f..5fe5368e562 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -746,7 +746,7 @@ describe("startGatewayConfigReloader", () => { await reloader.stop(); }); - it("skips last-known-good recovery for plugin-local invalid reloads", async () => { + it("queues restart in degraded mode for plugin-local invalid reloads", async () => { const activeConfig: OpenClawConfig = { gateway: { reload: { debounceMs: 0 } }, agents: { defaults: { model: "gpt-5.4" } }, @@ -779,7 +779,19 @@ describe("startGatewayConfigReloader", () => { .mockResolvedValueOnce(invalidSnapshot); const recoverSnapshot = vi.fn(async () => true); const promoteSnapshot = vi.fn(async () => true); + const previousConfig: OpenClawConfig = { + ...activeConfig, + plugins: { + entries: { + "lossless-claw": { + enabled: true, + config: { compactionMode: "adaptive" }, + }, + }, + }, + }; const { watcher, onHotReload, onRestart, log, reloader } = createReloaderHarness(readSnapshot, { + initialCompareConfig: previousConfig, recoverSnapshot, promoteSnapshot, }); @@ -790,13 +802,34 @@ describe("startGatewayConfigReloader", () => { expect(recoverSnapshot).not.toHaveBeenCalled(); expect(readSnapshot).toHaveBeenCalledTimes(1); expect(onHotReload).not.toHaveBeenCalled(); - expect(onRestart).not.toHaveBeenCalled(); + expect(onRestart).toHaveBeenCalledTimes(1); + expect(onRestart).toHaveBeenCalledWith( + expect.objectContaining({ + changedPaths: ["plugins.entries.lossless-claw.config.cacheAwareCompaction"], + restartGateway: true, + restartReasons: ["plugins.entries.lossless-claw.config.cacheAwareCompaction"], + }), + expect.objectContaining({ + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + "lossless-claw": expect.objectContaining({ + enabled: true, + config: expect.objectContaining({ + cacheAwareCompaction: true, + }), + }), + }), + }), + }), + ); expect(promoteSnapshot).not.toHaveBeenCalled(); expect(log.warn).toHaveBeenCalledWith( "config reload recovery skipped after invalid-config: invalidity is scoped to plugin entries", ); expect(log.warn).toHaveBeenCalledWith( - expect.stringContaining("config reload skipped (invalid config):"), + expect.stringContaining( + "config reload skipped plugin config validation issue at plugins.entries.lossless-claw.config.cacheAwareCompaction:", + ), ); await reloader.stop(); diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 38d6bc6be64..a61d8ed0c5b 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -3,10 +3,15 @@ import chokidar from "chokidar"; import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh-state.js"; import type { ConfigWriteNotification } from "../config/io.js"; import { formatConfigIssueLines } from "../config/issue-format.js"; -import { shouldAttemptLastKnownGoodRecovery } from "../config/recovery-policy.js"; +import { materializeRuntimeConfig } from "../config/materialize.js"; +import { + isPluginLocalInvalidConfigSnapshot, + shouldAttemptLastKnownGoodRecovery, +} from "../config/recovery-policy.js"; import { resolveConfigWriteFollowUp } from "../config/runtime-snapshot.js"; import type { GatewayReloadMode } from "../config/types.gateway.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js"; +import { validateConfigObjectWithPlugins } from "../config/validation.js"; import { isPlainObject } from "../utils.js"; import { buildGatewayReloadPlan, @@ -71,6 +76,39 @@ function isNoopReloadPlan(plan: GatewayReloadPlan): boolean { ); } +function resolvePluginLocalInvalidReloadSnapshot(params: { + snapshot: ConfigFileSnapshot; + log: { + warn: (msg: string) => void; + }; +}): ConfigFileSnapshot | null { + if (!isPluginLocalInvalidConfigSnapshot(params.snapshot)) { + return null; + } + const validated = validateConfigObjectWithPlugins(params.snapshot.sourceConfig, { + pluginValidation: "skip", + }); + if (!validated.ok) { + return null; + } + const runtimeConfig = materializeRuntimeConfig(validated.config, "load"); + for (const issue of params.snapshot.issues) { + params.log.warn( + `config reload skipped plugin config validation issue at ${issue.path}: ${issue.message}. Run "openclaw doctor --fix" to quarantine the plugin config.`, + ); + } + return { + ...params.snapshot, + sourceConfig: params.snapshot.sourceConfig, + resolved: params.snapshot.resolved, + valid: true, + runtimeConfig, + config: runtimeConfig, + issues: [], + warnings: [...params.snapshot.warnings, ...params.snapshot.issues, ...validated.warnings], + }; +} + export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] { if (prev === next) { return []; @@ -389,16 +427,28 @@ export function startGatewayConfigReloader(opts: { if (handleMissingSnapshot(snapshot)) { return; } + let degradedPluginSnapshot = false; if (!snapshot.valid) { const recoveredSnapshot = await recoverAndReadSnapshot(snapshot, "invalid-config"); if (!recoveredSnapshot) { - handleInvalidSnapshot(snapshot); - return; + const pluginLocalSnapshot = resolvePluginLocalInvalidReloadSnapshot({ + snapshot, + log: opts.log, + }); + if (!pluginLocalSnapshot) { + handleInvalidSnapshot(snapshot); + return; + } + snapshot = pluginLocalSnapshot; + degradedPluginSnapshot = true; + } else { + snapshot = recoveredSnapshot; } - snapshot = recoveredSnapshot; } await applySnapshot(snapshot.config, snapshot.sourceConfig); - await promoteAcceptedSnapshot(snapshot, "valid-config"); + if (!degradedPluginSnapshot) { + await promoteAcceptedSnapshot(snapshot, "valid-config"); + } } catch (err) { opts.log.error(`config reload failed: ${String(err)}`); } finally {