feat(FN-1399): add background memory summarization after task completion

- Add MemoryInsights class in @fusion/core for AI-powered memory audit generation
- Add post-run hook to CronRunner for triggering memory summarization after scheduled tasks
- Wire memory background processing in both dashboard and serve commands
- Add memoryAuditEnabled and memoryAuditSchedule settings for configurable automation
- Fix startup ordering: sync automation before cronRunner.start() to prevent race conditions
- Add comprehensive tests for memory-insights and dashboard/serve integration
- Update contributing.md and settings-reference.md with documentation
This commit is contained in:
gsxdsm
2026-04-09 16:14:44 -07:00
parent 8ba375549c
commit daabb51e71
13 changed files with 1803 additions and 4 deletions

View File

@@ -40,6 +40,16 @@ function makeMockStore() {
// ── Mock @fusion/core ──────────────────────────────────────────────────
const mockSyncInsightExtraction = vi.fn().mockResolvedValue(undefined);
const mockProcessAndAudit = vi.fn().mockResolvedValue({
generatedAt: new Date().toISOString(),
health: "healthy",
checks: [],
workingMemory: { exists: true, size: 100, sectionCount: 2 },
insightsMemory: { exists: true, size: 50, insightCount: 3, categories: {}, lastUpdated: "2026-04-09" },
extraction: { runAt: new Date().toISOString(), success: true, insightCount: 3, duplicateCount: 0, skippedCount: 0, summary: "Test" },
});
vi.mock("@fusion/core", () => ({
TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
AutomationStore: vi.fn().mockImplementation(() => ({
@@ -55,6 +65,9 @@ vi.mock("@fusion/core", () => ({
getAgent: vi.fn().mockResolvedValue(null),
deleteAgent: vi.fn(),
})),
syncInsightExtractionAutomation: mockSyncInsightExtraction,
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
processAndAuditInsightExtraction: mockProcessAndAudit,
}));
// ── Mock @fusion/dashboard ─────────────────────────────────────────────
@@ -381,3 +394,97 @@ describe("runDashboard — MissionAutopilot wiring", () => {
expect(autopilotInstance.start).toHaveBeenCalledTimes(1);
});
});
describe("runDashboard — Memory Insight Automation wiring", () => {
beforeEach(async () => {
vi.clearAllMocks();
mockDiscoverAndLoadExtensions.mockResolvedValue({
runtime: { pendingProviderRegistrations: [] },
errors: [],
});
const { TaskStore } = await import("@fusion/core");
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
});
it("syncs insight extraction automation on startup", async () => {
await runDashboard(0, {});
expect(mockSyncInsightExtraction).toHaveBeenCalledTimes(1);
expect(mockSyncInsightExtraction).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: false,
pollIntervalMs: 60_000,
}),
);
});
it("passes onScheduleRunProcessed callback to CronRunner", async () => {
const { CronRunner } = await import("@fusion/engine");
await runDashboard(0, {});
expect(CronRunner).toHaveBeenCalledTimes(1);
const cronOptions = (CronRunner as ReturnType<typeof vi.fn>).mock.calls[0][2];
expect(cronOptions).toHaveProperty("onScheduleRunProcessed");
expect(typeof cronOptions.onScheduleRunProcessed).toBe("function");
});
it("calls syncInsightExtractionAutomation when insight extraction settings change", async () => {
await runDashboard(0, {});
// Get the store mock to emit settings change
const { TaskStore } = await import("@fusion/core");
const mockStore = (TaskStore as unknown as ReturnType<typeof vi.fn>).mock.results[0].value;
// Simulate settings update
mockSyncInsightExtraction.mockClear();
mockStore.emit("settings:updated", {
settings: {
insightExtractionEnabled: true,
insightExtractionSchedule: "0 3 * * *",
},
previous: {
insightExtractionEnabled: false,
insightExtractionSchedule: "0 2 * * *",
},
});
expect(mockSyncInsightExtraction).toHaveBeenCalledTimes(1);
});
it("does not call syncInsightExtractionAutomation for unrelated settings changes", async () => {
await runDashboard(0, {});
// Get the store mock to emit settings change
const { TaskStore } = await import("@fusion/core");
const mockStore = (TaskStore as unknown as ReturnType<typeof vi.fn>).mock.results[0].value;
// Simulate unrelated settings update
mockSyncInsightExtraction.mockClear();
mockStore.emit("settings:updated", {
settings: {
maxConcurrent: 5,
},
previous: {
maxConcurrent: 1,
},
});
expect(mockSyncInsightExtraction).not.toHaveBeenCalled();
});
it("handles syncInsightExtractionAutomation errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockSyncInsightExtraction.mockRejectedValueOnce(new Error("Sync failed"));
await runDashboard(0, {});
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("[memory-audit] Failed to sync insight extraction"),
);
consoleSpy.mockRestore();
});
});

View File

@@ -263,6 +263,16 @@ vi.mock("@fusion/core", () => ({
AgentStore: mocks.agentStoreCtor,
CentralCore: mocks.centralCoreCtor,
getTaskMergeBlocker: vi.fn().mockReturnValue(null),
syncInsightExtractionAutomation: vi.fn().mockResolvedValue(undefined),
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
processAndAuditInsightExtraction: vi.fn().mockResolvedValue({
generatedAt: new Date().toISOString(),
health: "healthy",
checks: [],
workingMemory: { exists: true, size: 100, sectionCount: 2 },
insightsMemory: { exists: true, size: 50, insightCount: 3, categories: {}, lastUpdated: "2026-04-09" },
extraction: { runAt: new Date().toISOString(), success: true, insightCount: 3, duplicateCount: 0, skippedCount: 0, summary: "Test" },
}),
}));
vi.mock("@fusion/dashboard", () => ({
@@ -468,3 +478,143 @@ describe("runServe", () => {
await triggerSignal("SIGINT");
});
});
describe("runServe — Memory Insight Automation wiring", () => {
const originalCwd = process.cwd;
const originalOn = process.on;
const originalExit = process.exit;
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
let logSpy: ReturnType<typeof vi.spyOn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let cwdSpy: ReturnType<typeof vi.spyOn>;
let processOnSpy: ReturnType<typeof vi.spyOn>;
async function triggerSignal(signal: "SIGINT" | "SIGTERM") {
const handlers = signalHandlers[signal];
expect(handlers.length).toBeGreaterThan(0);
handlers[handlers.length - 1]();
await new Promise((resolve) => setTimeout(resolve, 0));
}
beforeEach(() => {
vi.clearAllMocks();
mocks.reset();
signalHandlers = { SIGINT: [], SIGTERM: [] };
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
if (event === "SIGINT" || event === "SIGTERM") {
signalHandlers[event].push(listener);
}
return process;
}) as typeof process.on);
process.exit = vi.fn() as never;
});
afterEach(() => {
logSpy.mockRestore();
warnSpy.mockRestore();
errorSpy.mockRestore();
cwdSpy.mockRestore();
processOnSpy.mockRestore();
process.cwd = originalCwd;
process.on = originalOn;
process.exit = originalExit;
});
it("syncs insight extraction automation on startup", async () => {
const { syncInsightExtractionAutomation } = await import("@fusion/core");
await runServe(4040, {});
expect(syncInsightExtractionAutomation).toHaveBeenCalledTimes(1);
expect(syncInsightExtractionAutomation).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
maxConcurrent: 2,
recycleWorktrees: false,
autoMerge: false,
pollIntervalMs: 60_000,
}),
);
await triggerSignal("SIGINT");
});
it("passes onScheduleRunProcessed callback to CronRunner", async () => {
await runServe(4040, {});
expect(mocks.cronRunnerCtor).toHaveBeenCalledTimes(1);
const cronOptions = mocks.cronRunnerCtor.mock.calls[0][2];
expect(cronOptions).toHaveProperty("onScheduleRunProcessed");
expect(typeof cronOptions.onScheduleRunProcessed).toBe("function");
await triggerSignal("SIGINT");
});
it("calls syncInsightExtractionAutomation when insight extraction settings change", async () => {
const { syncInsightExtractionAutomation } = await import("@fusion/core");
await runServe(4040, {});
// Simulate settings update
syncInsightExtractionAutomation.mockClear();
mocks.taskStores[0].emit("settings:updated", {
settings: {
insightExtractionEnabled: true,
insightExtractionSchedule: "0 3 * * *",
},
previous: {
insightExtractionEnabled: false,
insightExtractionSchedule: "0 2 * * *",
},
});
expect(syncInsightExtractionAutomation).toHaveBeenCalledTimes(1);
await triggerSignal("SIGINT");
});
it("does not call syncInsightExtractionAutomation for unrelated settings changes", async () => {
const { syncInsightExtractionAutomation } = await import("@fusion/core");
await runServe(4040, {});
// Simulate unrelated settings update
syncInsightExtractionAutomation.mockClear();
mocks.taskStores[0].emit("settings:updated", {
settings: {
maxConcurrent: 5,
},
previous: {
maxConcurrent: 2,
},
});
expect(syncInsightExtractionAutomation).not.toHaveBeenCalled();
await triggerSignal("SIGINT");
});
it("handles syncInsightExtractionAutomation errors gracefully", async () => {
const { syncInsightExtractionAutomation } = await import("@fusion/core");
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
syncInsightExtractionAutomation.mockRejectedValueOnce(new Error("Sync failed"));
await runServe(4040, {});
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("[memory-audit] Failed to sync insight extraction"),
);
consoleSpy.mockRestore();
await triggerSignal("SIGINT");
});
});