fix: degrade plugin-local reload invalidity

This commit is contained in:
Peter Steinberger
2026-04-28 02:24:54 +01:00
parent 06a80fa813
commit fe15268e5f
3 changed files with 92 additions and 8 deletions

View File

@@ -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.

View File

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

View File

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