Fix plugin reload lifecycle wiring

(cherry picked from commit 34a00a7d32)
This commit is contained in:
Paperclip Bot 2026-06-04 04:32:48 +00:00
parent 06a9428a36
commit 1580e3b755
3 changed files with 63 additions and 18 deletions

View file

@ -126,4 +126,35 @@ describe("pluginLifecycleManager.restartWorker", () => {
expect(started).toHaveBeenCalledTimes(1);
expect(started).toHaveBeenCalledWith({ pluginId: "plugin-1", pluginKey: "example.plugin" });
});
it("uses a late-bound runtime loader after bootstrap wiring completes", async () => {
mockRegistry.getById.mockResolvedValue(pluginRecord);
mockRegistry.updateStatus.mockResolvedValue(pluginRecord);
const { handle, workerManager } = makeWorkerManagerStub();
let runtimeLoader: Partial<PluginLoader> | undefined;
const lifecycle = pluginLifecycleManager(
{} as never,
{
resolveLoader: () => runtimeLoader as PluginLoader | undefined,
workerManager,
},
);
runtimeLoader = {
hasRuntimeServices: vi.fn().mockReturnValue(true) as PluginLoader["hasRuntimeServices"],
loadSingle: vi.fn().mockResolvedValue({
success: true,
plugin: pluginRecord,
registered: { worker: true, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 },
}) as PluginLoader["loadSingle"],
unloadSingle: vi.fn().mockResolvedValue(undefined) as PluginLoader["unloadSingle"],
};
await lifecycle.restartWorker("plugin-1");
expect(runtimeLoader.unloadSingle).toHaveBeenCalledWith("plugin-1", "example.plugin");
expect(runtimeLoader.loadSingle).toHaveBeenCalledWith("plugin-1");
expect(handle.restart).not.toHaveBeenCalled();
});
});

View file

@ -242,7 +242,11 @@ export async function createApp(
const eventBus = createPluginEventBus();
setPluginEventBus(eventBus);
const jobStore = pluginJobStore(db);
const lifecycle = pluginLifecycleManager(db, { workerManager });
let loader: ReturnType<typeof pluginLoader>;
const lifecycle = pluginLifecycleManager(db, {
workerManager,
resolveLoader: () => loader,
});
const scheduler = createPluginJobScheduler({
db,
jobStore,
@ -261,7 +265,7 @@ export async function createApp(
});
const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers);
let viteHtmlRenderer: ReturnType<typeof createCachedViteHtmlRenderer> | null = null;
const loader = pluginLoader(
loader = pluginLoader(
db,
{
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,

View file

@ -267,6 +267,8 @@ export interface PluginLifecycleManager {
export interface PluginLifecycleManagerOptions {
/** Plugin loader instance. Falls back to the default if omitted. */
loader?: PluginLoader;
/** Resolve the loader lazily for bootstrap paths with circular wiring. */
resolveLoader?: () => PluginLoader | undefined;
/**
* Worker process manager. When provided, lifecycle transitions that bring
@ -308,6 +310,7 @@ export function pluginLifecycleManager(
// Support the legacy signature: pluginLifecycleManager(db, loader)
// as well as the new options object form.
let loaderArg: PluginLoader | undefined;
let resolveLoader: (() => PluginLoader | undefined) | undefined;
let workerManager: PluginWorkerManager | undefined;
if (options && typeof options === "object" && "discoverAll" in options) {
@ -316,11 +319,12 @@ export function pluginLifecycleManager(
} else if (options && typeof options === "object") {
const opts = options as PluginLifecycleManagerOptions;
loaderArg = opts.loader;
resolveLoader = opts.resolveLoader;
workerManager = opts.workerManager;
}
const registry = pluginRegistryService(db);
const pluginLoaderInstance = loaderArg ?? pluginLoader(db);
const fallbackLoader = loaderArg ?? pluginLoader(db);
const emitter = new EventEmitter();
emitter.setMaxListeners(100); // plugins may have many listeners; 100 is a safe upper bound
@ -386,6 +390,10 @@ export function pluginLifecycleManager(
emitter.emit(event, payload);
}
function currentLoader(): PluginLoader {
return resolveLoader?.() ?? loaderArg ?? fallbackLoader;
}
// -----------------------------------------------------------------------
// Worker management helpers
// -----------------------------------------------------------------------
@ -415,14 +423,15 @@ export function pluginLifecycleManager(
}
async function activateReadyPlugin(pluginId: string): Promise<void> {
const loader = currentLoader();
const supportsRuntimeActivation =
typeof pluginLoaderInstance.hasRuntimeServices === "function"
&& typeof pluginLoaderInstance.loadSingle === "function";
if (!supportsRuntimeActivation || !pluginLoaderInstance.hasRuntimeServices()) {
typeof loader.hasRuntimeServices === "function"
&& typeof loader.loadSingle === "function";
if (!supportsRuntimeActivation || !loader.hasRuntimeServices()) {
return;
}
const loadResult = await pluginLoaderInstance.loadSingle(pluginId);
const loadResult = await loader.loadSingle(pluginId);
if (!loadResult.success) {
throw new Error(
loadResult.error
@ -435,12 +444,13 @@ export function pluginLifecycleManager(
pluginId: string,
pluginKey: string,
): Promise<void> {
const loader = currentLoader();
const supportsRuntimeDeactivation =
typeof pluginLoaderInstance.hasRuntimeServices === "function"
&& typeof pluginLoaderInstance.unloadSingle === "function";
typeof loader.hasRuntimeServices === "function"
&& typeof loader.unloadSingle === "function";
if (supportsRuntimeDeactivation && pluginLoaderInstance.hasRuntimeServices()) {
await pluginLoaderInstance.unloadSingle(pluginId, pluginKey);
if (supportsRuntimeDeactivation && loader.hasRuntimeServices()) {
await loader.unloadSingle(pluginId, pluginKey);
return;
}
@ -541,7 +551,7 @@ export function pluginLifecycleManager(
// If already uninstalled and removeData, hard-delete
if (plugin.status === "uninstalled") {
if (removeData) {
await pluginLoaderInstance.cleanupInstallArtifacts(plugin);
await currentLoader().cleanupInstallArtifacts(plugin);
const deleted = await registry.uninstall(pluginId, true);
log.info(
{ pluginId, pluginKey: plugin.pluginKey },
@ -561,7 +571,7 @@ export function pluginLifecycleManager(
}
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
await pluginLoaderInstance.cleanupInstallArtifacts(plugin);
await currentLoader().cleanupInstallArtifacts(plugin);
// Perform the uninstall via registry (handles soft/hard delete)
const result = await registry.uninstall(pluginId, removeData);
@ -656,7 +666,7 @@ export function pluginLifecycleManager(
// 1. Download and validate new package via loader
const { oldManifest, newManifest, discovered } =
await pluginLoaderInstance.upgradePlugin(pluginId, { version });
await currentLoader().upgradePlugin(pluginId, { version });
log.info(
{
@ -777,10 +787,10 @@ export function pluginLifecycleManager(
}
const supportsRuntimeActivation =
typeof pluginLoaderInstance.hasRuntimeServices === "function"
&& typeof pluginLoaderInstance.loadSingle === "function"
&& typeof pluginLoaderInstance.unloadSingle === "function"
&& pluginLoaderInstance.hasRuntimeServices();
typeof currentLoader().hasRuntimeServices === "function"
&& typeof currentLoader().loadSingle === "function"
&& typeof currentLoader().unloadSingle === "function"
&& currentLoader().hasRuntimeServices();
if (supportsRuntimeActivation) {
log.info(