feat(FN-4649): complete Step 1 — add core task-created hook

Fusion-Task-Id: FN-4649
Fusion-Task-Lineage: 98ab6ec3-5253-4db6-93f0-de4ab7792002
This commit is contained in:
Fusion (runfusion.ai)
2026-05-15 19:50:26 -07:00
committed by gsxdsm
parent 8b371bae04
commit 4c3cd0b406
4 changed files with 150 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setTaskCreatedHook } from "../task-creation-hooks.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("task creation hook", () => {
const harness = createTaskStoreTestHarness();
beforeEach(async () => {
setTaskCreatedHook(undefined);
await harness.beforeEach();
});
afterEach(async () => {
setTaskCreatedHook(undefined);
await harness.afterEach();
});
it("fires once for createTask and createTaskWithReservedId", async () => {
const store = harness.store();
const hook = vi.fn();
setTaskCreatedHook(hook);
const created = await store.createTask({ description: "a" });
const reserved = await store.createTaskWithReservedId({ description: "b" }, { taskId: "FN-9101" });
expect(hook).toHaveBeenCalledTimes(2);
expect(hook).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: created.id }), store);
expect(hook).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: reserved.id }), store);
});
async function moveToDone(taskId: string): Promise<void> {
const store = harness.store();
await store.moveTask(taskId, "todo");
await store.moveTask(taskId, "in-progress");
await store.moveTask(taskId, "in-review");
await store.moveTask(taskId, "done");
}
it("fires for duplicateTask and refineTask", async () => {
const store = harness.store();
const source = await store.createTask({ description: "source", title: "Source" });
await moveToDone(source.id);
const hook = vi.fn();
setTaskCreatedHook(hook);
const duplicated = await store.duplicateTask(source.id);
const refined = await store.refineTask(source.id, "please refine");
expect(hook).toHaveBeenCalledTimes(2);
expect(hook).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: duplicated.id }), store);
expect(hook).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: refined.id }), store);
});
it("does not fire for applyReplicatedTaskCreate", async () => {
const store = harness.store();
const hook = vi.fn();
setTaskCreatedHook(hook);
await store.applyReplicatedTaskCreate({
replicationVersion: 1,
reservationId: "res-1",
taskId: "FN-9102",
sourceNodeId: "node-a",
createdAt: "2026-05-05T00:00:00.000Z",
updatedAt: "2026-05-05T00:00:00.000Z",
prompt: "# FN-9102\n\nreplicated\n",
input: { description: "replicated", column: "triage" },
});
expect(hook).not.toHaveBeenCalled();
});
it("swallows sync and async hook failures and still returns tasks", async () => {
const store = harness.store();
setTaskCreatedHook(() => {
throw new Error("boom");
});
const created = await store.createTask({ description: "a" });
const duplicated = await store.duplicateTask(created.id);
await moveToDone(created.id);
const refined = await store.refineTask(created.id, "feedback");
expect(created.id).toMatch(/^FN-/);
expect(duplicated.id).toMatch(/^FN-/);
expect(refined.id).toMatch(/^FN-/);
setTaskCreatedHook(async () => {
throw new Error("async boom");
});
const created2 = await store.createTask({ description: "b" });
expect(created2.id).toMatch(/^FN-/);
});
it("can clear hook with undefined", async () => {
const store = harness.store();
const hook = vi.fn();
setTaskCreatedHook(hook);
setTaskCreatedHook(undefined);
await store.createTask({ description: "a" });
expect(hook).not.toHaveBeenCalled();
});
});

View File

@@ -32,6 +32,11 @@ export {
getCreateAiSessionFactory,
type AgentMessage,
} from "./ai-engine-loader.js";
export {
setTaskCreatedHook,
getTaskCreatedHook,
type TaskCreatedHook,
} from "./task-creation-hooks.js";
// ── Prompt Overrides ─────────────────────────────────────────────────
export {

View File

@@ -32,6 +32,8 @@ import { runCommandAsync } from "./run-command.js";
import { createLogger } from "./logger.js";
import { validateNodeOverrideChange } from "./node-override-guard.js";
import { sanitizeTitle } from "./ai-summarize.js";
import { getErrorMessage } from "./error-message.js";
import { getTaskCreatedHook } from "./task-creation-hooks.js";
import { assertProjectRootDir } from "./project-root-guard.js";
import { generateTaskLineageId, normalizeTaskCommitAssociation } from "./task-lineage.js";
import { createDistributedTaskIdAllocator, reconcileTaskIdState, resolveLocalNodeId, type DistributedTaskIdAllocator } from "./distributed-task-id.js";
@@ -2904,6 +2906,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
updatedAt?: string;
prompt?: string;
applyDefaultWorkflowSteps?: boolean;
invokeTaskCreatedHook?: boolean;
},
): Promise<Task> {
if (!input.description?.trim()) {
@@ -2951,10 +2954,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
createdAt: options.createdAt,
updatedAt: options.updatedAt,
promptOverride: options.prompt,
invokeTaskCreatedHook: options.invokeTaskCreatedHook,
});
}
async applyReplicatedTaskCreate(payload: MeshReplicatedTaskCreatePayload): Promise<MeshReplicatedTaskApplyResult> {
// Intentionally does not invoke the post-create hook. Replicated tasks mirror
// state from an origin node; rerunning side effects here (e.g. GitHub issue
// creation) would duplicate external artifacts.
const existing = this.readTaskFromDb(payload.taskId);
if (existing) {
const existingDetail = await this.getTask(payload.taskId);
@@ -2970,6 +2977,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
updatedAt: payload.updatedAt,
prompt: payload.prompt,
applyDefaultWorkflowSteps: false,
invokeTaskCreatedHook: false,
});
return { task, applied: true };
@@ -2988,6 +2996,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
createdAt?: string;
updatedAt?: string;
promptOverride?: string;
invokeTaskCreatedHook?: boolean;
},
): Promise<Task> {
const now = options?.createdAt ?? new Date().toISOString();
@@ -3066,9 +3075,22 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
await writeFile(join(dir, "PROMPT.md"), prompt);
this.emit("task:created", task);
if (options?.invokeTaskCreatedHook !== false) {
await this.invokeTaskCreatedHook(task);
}
return task;
}
private async invokeTaskCreatedHook(task: Task): Promise<void> {
const taskCreatedHook = getTaskCreatedHook();
if (!taskCreatedHook) return;
try {
await taskCreatedHook(task, this);
} catch (error) {
storeLog.warn(`[task-created-hook] ${task.id}: ${getErrorMessage(error)}`);
}
}
/**
* Duplicate an existing task, creating a fresh copy in triage.
* Copies title and description with source reference, but resets all
@@ -3118,6 +3140,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (this.isWatching) this.taskCache.set(newId, { ...newTask });
this.emit("task:created", newTask);
await this.invokeTaskCreatedHook(newTask);
return newTask;
},
});
@@ -3208,6 +3231,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (this.isWatching) this.taskCache.set(newId, { ...newTask });
this.emit("task:created", newTask);
await this.invokeTaskCreatedHook(newTask);
return newTask;
},
});

View File

@@ -0,0 +1,14 @@
import type { Task } from "./types.js";
import type { TaskStore } from "./store.js";
export type TaskCreatedHook = (task: Task, store: TaskStore) => Promise<void> | void;
let taskCreatedHook: TaskCreatedHook | undefined;
export function setTaskCreatedHook(fn: TaskCreatedHook | undefined): void {
taskCreatedHook = fn;
}
export function getTaskCreatedHook(): TaskCreatedHook | undefined {
return taskCreatedHook;
}