Files
openclaw/src/plugins/plugin-registration-transaction.test.ts
Qiong 977db1c832 fix(plugins): clean scheduler jobs after failed registration (#106890)
* fix(plugins): cleanup session scheduler jobs on failed registration rollback (#106825)

Failed plugin registration rolls back registry metadata and global side effects
but leaves live session-scheduler records in the module-global runtime state
uncleaned. The cleanup callback that owns external scheduler resources is never
invoked at the rollback boundary.

Add the existing owner-scoped cleanupPluginSessionSchedulerJobs to the
rollbackPluginGlobalSideEffects function so live records are removed and their
cleanup callbacks are called when a registration transaction is rolled back.

Fixes #106825

* fix(plugins): scope scheduler rollback cleanup

Co-authored-by: Qiong <yang.ji2@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-23 10:26:10 -07:00

183 lines
6.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { clearPluginHostRuntimeState } from "./host-hook-runtime.js";
import { listPluginSessionSchedulerJobs } from "./host-hook-runtime.test-fixtures.js";
import { clearActivatedPluginRuntimeState } from "./loader-shared.js";
import {
getMemoryCapabilityRegistration,
registerMemoryCapability,
} from "./memory-state.test-fixtures.js";
import {
createPluginRegistrationTransaction,
type PluginProcessGlobalState,
restorePluginProcessGlobalState,
snapshotPluginProcessGlobalState,
} from "./plugin-registration-transaction.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import { createPluginRegistry } from "./registry.js";
import type { PluginRuntime } from "./runtime/types.js";
import {
getSessionDiscussionProvider,
registerSessionDiscussionProvider,
type SessionDiscussionProvider,
} from "./session-discussion-registry.js";
import { createPluginRecord } from "./status.test-helpers.js";
function discussionProvider(id: string): SessionDiscussionProvider {
return {
id,
info: vi.fn().mockResolvedValue({ state: "available" }),
open: vi.fn().mockResolvedValue({ state: "open" }),
};
}
function createSchedulerPlugin(pluginId: string) {
const pluginRegistry = createPluginRegistry({
logger: { info() {}, warn() {}, error() {}, debug() {} },
runtime: {} as PluginRuntime,
});
const api = pluginRegistry.createApi(createPluginRecord({ id: pluginId }), {
config: {} as OpenClawConfig,
});
return { api, pluginRegistry };
}
describe("plugin registration transaction", () => {
let initialProcessGlobalState: PluginProcessGlobalState;
beforeEach(() => {
initialProcessGlobalState = snapshotPluginProcessGlobalState();
});
afterEach(() => {
clearPluginHostRuntimeState();
restorePluginProcessGlobalState(initialProcessGlobalState);
});
it("rolls back registry writes and restores prior process-global capability state", () => {
const registry = createEmptyPluginRegistry();
const activePromptBuilder = () => ["active"];
const failedResolver = () => "failed";
const rollbackGlobalSideEffects = vi.fn();
registerMemoryCapability("active-memory", { promptBuilder: activePromptBuilder });
const transaction = createPluginRegistrationTransaction({
registry,
rollbackGlobalSideEffects,
});
registry.hostedMediaResolvers.push({
pluginId: "failed-plugin",
resolver: failedResolver,
source: "failed-plugin",
});
registry.gatewayHandlers.failed = async () => {};
registerMemoryCapability("failed-memory", { promptBuilder: () => ["failed"] });
transaction.rollback();
expect(rollbackGlobalSideEffects).toHaveBeenCalledOnce();
expect(registry.hostedMediaResolvers).toStrictEqual([]);
expect(registry.gatewayHandlers).toStrictEqual({});
expect(getMemoryCapabilityRegistration()).toEqual({
pluginId: "active-memory",
capability: { promptBuilder: activePromptBuilder },
});
});
it("keeps snapshot registry writes while restoring globals for non-activating commits", () => {
const registry = createEmptyPluginRegistry();
const activePromptBuilder = () => ["active"];
const snapshotResolver = () => "snapshot";
registerMemoryCapability("active-memory", { promptBuilder: activePromptBuilder });
const transaction = createPluginRegistrationTransaction({ registry });
registry.hostedMediaResolvers.push({
pluginId: "snapshot-plugin",
resolver: snapshotResolver,
source: "snapshot-plugin",
});
registerMemoryCapability("snapshot-memory", { promptBuilder: () => ["snapshot"] });
transaction.commit({ activate: false });
expect(registry.hostedMediaResolvers).toEqual([
{
pluginId: "snapshot-plugin",
resolver: snapshotResolver,
source: "snapshot-plugin",
},
]);
expect(getMemoryCapabilityRegistration()).toEqual({
pluginId: "active-memory",
capability: { promptBuilder: activePromptBuilder },
});
});
it("clears the discussion provider before repeated active plugin activation", () => {
registerSessionDiscussionProvider(discussionProvider("clickclack"));
clearActivatedPluginRuntimeState();
expect(getSessionDiscussionProvider()).toBeUndefined();
});
it("restores the prior discussion provider when plugin activation rolls back", () => {
const activeProvider = discussionProvider("clickclack");
registerSessionDiscussionProvider(activeProvider);
const transaction = createPluginRegistrationTransaction({});
registerSessionDiscussionProvider(discussionProvider("replacement"));
transaction.rollback();
expect(getSessionDiscussionProvider()).toBe(activeProvider);
});
it("rolls back only scheduler jobs owned by the failed registry", async () => {
const pluginId = "scheduler-plugin";
const failedCleanup = vi.fn();
const activeCleanup = vi.fn();
const active = createSchedulerPlugin(pluginId);
const failed = createSchedulerPlugin(pluginId);
active.api.registerSessionSchedulerJob({
id: "active-job",
sessionKey: "agent:main:main",
kind: "monitor",
cleanup: activeCleanup,
});
const transaction = createPluginRegistrationTransaction({
registry: failed.pluginRegistry.registry,
rollbackGlobalSideEffects: () =>
failed.pluginRegistry.rollbackPluginGlobalSideEffects(pluginId),
});
failed.api.registerSessionSchedulerJob({
id: "failed-job",
sessionKey: "agent:main:main",
kind: "monitor",
cleanup: failedCleanup,
});
transaction.rollback();
await vi.waitFor(() => {
expect(failedCleanup).toHaveBeenCalledOnce();
});
expect(failedCleanup).toHaveBeenCalledWith({
reason: "disable",
sessionKey: "agent:main:main",
jobId: "failed-job",
});
expect(activeCleanup).not.toHaveBeenCalled();
expect(listPluginSessionSchedulerJobs(pluginId)).toStrictEqual([
{
id: "active-job",
pluginId,
sessionKey: "agent:main:main",
kind: "monitor",
},
]);
expect(failed.pluginRegistry.registry.sessionSchedulerJobs).toStrictEqual([]);
expect(active.pluginRegistry.registry.sessionSchedulerJobs).toHaveLength(1);
});
});