FN-7397: pass plugin context to lifecycle hooks

Ensure task lifecycle plugin hooks receive a PluginContext when fired through runtime event bridges.

- Append the plugin context for task-created, task-moved, and task-completed hooks when callers only provide raw lifecycle arguments.
- Preserve existing explicit-context hook calls and add coverage for loader and runner lifecycle dispatch paths.
- Cover Compound Engineering sync completion handling so taskStore is available in onTaskCompleted.

Files changed:
 .changeset/fn-7397-plugin-hook-context.md          |  7 +++
 packages/core/src/__tests__/plugin-loader.test.ts  | 52 +++++++++++++++++++++-
 packages/core/src/plugin-loader.ts                 | 34 +++++++++++++-
 .../engine/src/__tests__/plugin-runner.test.ts     | 25 ++++++++++-
 .../src/__tests__/sync.test.ts                     | 29 +++++++++++-
 5 files changed, 141 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-7397

Fusion-Task-Lineage: 7661714c-cf2a-4f4d-bec1-fc6bc11c0613

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 13:55:25 -07:00
parent 07aa1a0085
commit f998fe3ffc
5 changed files with 141 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Ensure task lifecycle plugins receive runtime context during completion hooks.
category: fix
dev: PluginLoader now appends PluginContext to task lifecycle hook invocations when callers provide only task event args.

View File

@@ -20,7 +20,7 @@ vi.mock("@earendil-works/pi-ai", () => ({
}));
import { PluginStore } from "../plugin-store.js";
import { setCreateAiSessionFactory } from "../ai-engine-loader.js";
import type { CreateAiSessionOptions, FusionPlugin, PluginManifest } from "../plugin-types.js";
import type { CreateAiSessionOptions, FusionPlugin, PluginContext, PluginManifest } from "../plugin-types.js";
// Test plugin manifest
function makeManifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
@@ -82,6 +82,8 @@ async function writePluginWithHooks(
onLoad?: string;
onUnload?: string;
onTaskCreated?: string;
onTaskMoved?: string;
onTaskCompleted?: string;
onError?: string;
},
manifest: PluginManifest,
@@ -1135,6 +1137,54 @@ export default plugin;
expect(hookB).toHaveBeenCalledTimes(1);
});
it("passes PluginContext to task lifecycle hooks invoked through the loader", async () => {
await pluginStore.init();
await pluginStore.registerPlugin({
manifest: makeManifest({ id: "context-hook" }),
path: "/virtual/context-hook.js",
settings: { mode: "runtime" },
});
const onTaskCreated = vi.fn();
const onTaskMoved = vi.fn();
const onTaskCompleted = vi.fn();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
(loader as any).plugins.set("context-hook", {
manifest: makeManifest({ id: "context-hook" }),
state: "started",
hooks: { onTaskCreated, onTaskMoved, onTaskCompleted },
tools: [],
routes: [],
} as FusionPlugin);
const task = { id: "FN-001" } as any;
await loader.invokeHook("onTaskCreated", task);
await loader.invokeHook("onTaskMoved", task, "todo", "done");
await loader.invokeHook("onTaskCompleted", task);
const createdCtx = onTaskCreated.mock.calls[0]?.[1] as PluginContext | undefined;
const movedCtx = onTaskMoved.mock.calls[0]?.[3] as PluginContext | undefined;
const completedCtx = onTaskCompleted.mock.calls[0]?.[1] as PluginContext | undefined;
for (const hookCtx of [createdCtx, movedCtx, completedCtx]) {
expect(hookCtx).toMatchObject({
pluginId: "context-hook",
taskStore: mockTaskStore,
settings: { mode: "runtime" },
});
expect(hookCtx?.logger).toEqual(expect.objectContaining({
info: expect.any(Function),
warn: expect.any(Function),
error: expect.any(Function),
debug: expect.any(Function),
}));
expect(hookCtx?.emitEvent).toEqual(expect.any(Function));
}
});
it("continues when one plugin's hook fails", async () => {
await pluginStore.init();

View File

@@ -902,14 +902,44 @@ export class PluginLoader extends EventEmitter<{
const hook = plugin.hooks[hookName];
if (!hook) return;
const fn = hook as (...args: unknown[]) => unknown;
const result = fn(...args);
const result = fn(...await this.withLifecycleHookContext(plugin, hookName, args));
if (result instanceof Promise) {
await result;
}
}
private async withLifecycleHookContext(
plugin: FusionPlugin,
hookName: keyof FusionPlugin["hooks"],
args: unknown[],
): Promise<unknown[]> {
if (!this.isTaskLifecycleHook(hookName) || this.hasPluginContext(args.at(-1))) {
return args;
}
/*
FNXC:PluginHooks 2026-07-01-13:36:
Runtime task lifecycle hooks are invoked from fire-and-forget TaskStore event bridges, but the public hook contract still requires a per-plugin PluginContext. Append the context in PluginLoader so all runtime callers keep the fast raw event-argument path while plugins consistently receive taskStore, settings, logger, and emitEvent.
*/
return [...args, await this.createContext(plugin)];
}
private isTaskLifecycleHook(hookName: keyof FusionPlugin["hooks"]): boolean {
return hookName === "onTaskCreated" || hookName === "onTaskMoved" || hookName === "onTaskCompleted";
}
private hasPluginContext(value: unknown): value is PluginContext {
return Boolean(
value
&& typeof value === "object"
&& "taskStore" in value
&& "settings" in value
&& "logger" in value
&& "emitEvent" in value,
);
}
async checkPluginSetup(pluginId: string): Promise<PluginSetupCheckResult> {
const plugin = this.plugins.get(pluginId);
if (!plugin) {

View File

@@ -1478,8 +1478,20 @@ describe("PluginRunner", () => {
);
});
it("should invoke onTaskCompleted when task moves to done", async () => {
mockPluginLoader.invokeHook = vi.fn();
it("should invoke onTaskCompleted through the loader seam when task moves to done", async () => {
const completedHook = vi.fn();
const runtimeCtx = {
pluginId: "test-plugin",
taskStore: mockTaskStore,
settings: {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
};
mockPluginLoader.invokeHook = vi.fn(async (hookName: keyof FusionPlugin["hooks"], ...args: unknown[]) => {
if (hookName === "onTaskCompleted") {
completedHook(args[0], runtimeCtx);
}
});
await pluginRunner.init();
// Find the task:moved handler
@@ -1500,6 +1512,15 @@ describe("PluginRunner", () => {
"onTaskCompleted",
mockTask
);
expect(completedHook).toHaveBeenCalledWith(
mockTask,
expect.objectContaining({
taskStore: mockTaskStore,
settings: {},
logger: expect.any(Object),
emitEvent: expect.any(Function),
})
);
});
it("should NOT invoke onTaskCompleted when task moves elsewhere", async () => {

View File

@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { InteractiveAiSessionEvent, PluginContext, Task } from "@fusion/core";
import { TaskStore } from "@fusion/core";
import { PluginLoader, PluginStore, TaskStore } from "@fusion/core";
import plugin, {
CeOrchestrator,
CE_PLUGIN_ID,
@@ -137,6 +137,33 @@ describe("U8 inbound hooks (board → pipeline)", () => {
// The hook awaits NOTHING heavy; it returns synchronously-ish.
expect(Date.now() - start).toBeLessThan(1000);
});
it("runtime loader invocation gives onTaskCompleted a context and enqueues task_completed sync", async () => {
const pluginStore = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: rootDir });
await pluginStore.init();
await pluginStore.registerPlugin({
manifest: plugin.manifest,
path: join(rootDir, "compound-engineering.js"),
settings: { reconcileOnHooks: false },
});
const loader = new PluginLoader({ pluginStore, taskStore });
(loader as unknown as { plugins: Map<string, typeof plugin> }).plugins.set(CE_PLUGIN_ID, plugin);
const { task } = await landPipeline("plan");
await moveTo(task.id, "done");
await expect(loader.invokeHook("onTaskCompleted", { ...task, column: "done" })).resolves.toBeUndefined();
const installed = await pluginStore.getPlugin(CE_PLUGIN_ID);
expect(installed.state).not.toBe("error");
const pending = getCePipelineStore(ctx).listPendingSync();
expect(pending).toContainEqual(expect.objectContaining({
taskId: task.id,
reason: "task_completed",
fromColumn: null,
toColumn: "done",
processedAt: null,
}));
});
});
describe("U8 reconciler (convergence + outbound)", () => {