mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:40:42 +00:00
fix(plugins): avoid doctor crash on legacy interactive state (#70135)
* fix(plugins): hydrate legacy interactive state * fix(plugins): avoid doctor crash on legacy interactive state (#70135) (thanks @ngutman)
This commit is contained in:
@@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord: keep slash command follow-up chunks ephemeral when the command is configured for ephemeral replies, so long `/status` output no longer leaks fallback model or runtime details into the public channel. (#69869) thanks @gumadeiras.
|
||||
- Plugins/discovery: reject package plugin source entries that escape the package directory before explicit runtime entries or inferred built JavaScript peers can be used. (#69868) thanks @gumadeiras.
|
||||
- CLI/channels: resolve channel presence through a shared policy that keeps ambient env vars and stale persisted auth from surfacing disabled bundled plugins in status, doctor, security audit, and cron delivery validation unless the channel or plugin is effectively enabled or explicitly configured. (#69862) Thanks @gumadeiras.
|
||||
- Doctor/plugins: hydrate legacy partial interactive handler state before plugin reload clears dedupe caches, so `openclaw doctor` and post-update doctor runs no longer crash with `Cannot read properties of undefined (reading 'clear')`. (#70135) Thanks @ngutman.
|
||||
- Control UI/config: preserve intentionally empty raw config snapshots when clearing pending updates so reset restores the original bytes instead of synthesizing JSON for blank config files. (#68178) Thanks @BunsDev.
|
||||
- memory-core/dreaming: surface a `Dreaming status: blocked` line in `openclaw memory status` when dreaming is enabled but the heartbeat that drives the managed cron is not firing for the default agent, and add a Troubleshooting section to the dreaming docs covering the two common causes (per-agent `heartbeat` blocks excluding `main`, and `heartbeat.every` set to `0`/empty/invalid), so the silent failure described in #69843 becomes legible on the status surface.
|
||||
- Cron/run-log: report generic `message` tool sends under the resolved delivery channel when they match the cron target, while preserving account-specific mismatch checks for delivery traces. (#69940) Thanks @davehappyminion.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createDedupeCache, resolveGlobalDedupeCache } from "../infra/dedupe.js";
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
import type { DedupeCache } from "../infra/dedupe.js";
|
||||
import type { PluginInteractiveHandlerRegistration } from "./types.js";
|
||||
|
||||
export type RegisteredInteractiveHandler = PluginInteractiveHandlerRegistration & {
|
||||
@@ -15,19 +15,56 @@ type InteractiveState = {
|
||||
};
|
||||
|
||||
const PLUGIN_INTERACTIVE_STATE_KEY = Symbol.for("openclaw.pluginInteractiveState");
|
||||
const PLUGIN_INTERACTIVE_CALLBACK_DEDUPE_KEY = Symbol.for(
|
||||
"openclaw.pluginInteractiveCallbackDedupe",
|
||||
);
|
||||
|
||||
function createInteractiveCallbackDedupe(): DedupeCache {
|
||||
return resolveGlobalDedupeCache(PLUGIN_INTERACTIVE_CALLBACK_DEDUPE_KEY, {
|
||||
ttlMs: 5 * 60_000,
|
||||
maxSize: 4096,
|
||||
});
|
||||
}
|
||||
|
||||
function createInteractiveState(): InteractiveState {
|
||||
return {
|
||||
interactiveHandlers: new Map<string, RegisteredInteractiveHandler>(),
|
||||
callbackDedupe: createInteractiveCallbackDedupe(),
|
||||
inflightCallbackDedupe: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
function hydrateInteractiveState(value: unknown): InteractiveState {
|
||||
const state =
|
||||
typeof value === "object" && value !== null
|
||||
? (value as Partial<InteractiveState>)
|
||||
: ({} as Partial<InteractiveState>);
|
||||
|
||||
return {
|
||||
interactiveHandlers:
|
||||
state.interactiveHandlers instanceof Map
|
||||
? state.interactiveHandlers
|
||||
: new Map<string, RegisteredInteractiveHandler>(),
|
||||
callbackDedupe: createInteractiveCallbackDedupe(),
|
||||
inflightCallbackDedupe:
|
||||
state.inflightCallbackDedupe instanceof Set
|
||||
? state.inflightCallbackDedupe
|
||||
: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return resolveGlobalSingleton<InteractiveState>(PLUGIN_INTERACTIVE_STATE_KEY, () => ({
|
||||
interactiveHandlers: new Map<string, RegisteredInteractiveHandler>(),
|
||||
callbackDedupe: resolveGlobalDedupeCache(
|
||||
Symbol.for("openclaw.pluginInteractiveCallbackDedupe"),
|
||||
{
|
||||
ttlMs: 5 * 60_000,
|
||||
maxSize: 4096,
|
||||
},
|
||||
),
|
||||
inflightCallbackDedupe: new Set<string>(),
|
||||
}));
|
||||
const globalStore = globalThis as Record<PropertyKey, unknown>;
|
||||
const existing = globalStore[PLUGIN_INTERACTIVE_STATE_KEY];
|
||||
if (existing !== undefined) {
|
||||
const hydrated = hydrateInteractiveState(existing);
|
||||
globalStore[PLUGIN_INTERACTIVE_STATE_KEY] = hydrated;
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
const created = createInteractiveState();
|
||||
globalStore[PLUGIN_INTERACTIVE_STATE_KEY] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
export function getPluginInteractiveHandlersState() {
|
||||
|
||||
@@ -480,6 +480,61 @@ describe("plugin interactive handlers", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("hydrates legacy interactive state shapes before clearing handlers", async () => {
|
||||
const globalStore = globalThis as Record<PropertyKey, unknown>;
|
||||
const stateKey = Symbol.for("openclaw.pluginInteractiveState");
|
||||
const originalState = globalStore[stateKey];
|
||||
|
||||
globalStore[stateKey] = {
|
||||
interactiveHandlers: new Map(),
|
||||
};
|
||||
|
||||
try {
|
||||
expect(() => clearPluginInteractiveHandlers()).not.toThrow();
|
||||
const hydrated = globalStore[stateKey] as {
|
||||
interactiveHandlers?: Map<string, unknown>;
|
||||
callbackDedupe?: { clear: () => void };
|
||||
inflightCallbackDedupe?: Set<string>;
|
||||
};
|
||||
expect(hydrated.interactiveHandlers).toBeInstanceOf(Map);
|
||||
expect(hydrated.callbackDedupe?.clear).toEqual(expect.any(Function));
|
||||
expect(hydrated.inflightCallbackDedupe).toBeInstanceOf(Set);
|
||||
|
||||
const handler = vi.fn(async () => ({ handled: true }));
|
||||
expect(
|
||||
registerPluginInteractiveHandler("codex-plugin", {
|
||||
channel: "telegram",
|
||||
namespace: "legacy",
|
||||
handler,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
|
||||
await expect(
|
||||
dispatchInteractive(
|
||||
createTelegramDispatchParams({
|
||||
data: "legacy:resume",
|
||||
callbackId: "legacy-state-cb",
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual({ matched: true, handled: true, duplicate: false });
|
||||
await expect(
|
||||
dispatchInteractive(
|
||||
createTelegramDispatchParams({
|
||||
data: "legacy:resume",
|
||||
callbackId: "legacy-state-cb",
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual({ matched: true, handled: true, duplicate: true });
|
||||
} finally {
|
||||
if (originalState === undefined) {
|
||||
delete globalStore[stateKey];
|
||||
} else {
|
||||
globalStore[stateKey] = originalState;
|
||||
}
|
||||
clearPluginInteractiveHandlers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "routes Telegram callbacks by namespace and dedupes callback ids",
|
||||
|
||||
Reference in New Issue
Block a user