fix(FN-2364): harden diagnostics context and plugin reload imports

- Add explicit operation metadata to agent-generation and ai-session-store diagnostics for cleanup, recovery, and scheduled cleanup paths
- Extend dashboard guardrail coverage to explicitly enforce diagnostics protection for agent-generation.ts and ai-session-store.ts
- Stabilize plugin module reload imports by using temporary reload files with deterministic file URLs and cache updates
- Tighten CLI Vitest workspace cleanup to safely skip missing hidden dist directories during restore
This commit is contained in:
Fusion
2026-04-24 01:34:34 -07:00
committed by gsxdsm
parent 5e9cd88708
commit 4f9f36fe75
7 changed files with 58 additions and 42 deletions

View File

@@ -28,12 +28,15 @@ function hideInternalPackageDistDirs() {
function restoreInternalPackageDistDirs() {
for (let i = movedDistDirs.length - 1; i >= 0; i--) {
const { from, to } = movedDistDirs[i];
if (existsSync(to)) {
if (existsSync(from)) {
rmSync(from, { recursive: true, force: true });
}
renameSync(to, from);
if (!existsSync(to)) {
continue;
}
if (existsSync(from)) {
rmSync(from, { recursive: true, force: true });
}
renameSync(to, from);
}
movedDistDirs.length = 0;
}

View File

@@ -9,9 +9,8 @@
* - Error isolation (plugin crashes don't crash the loader)
*/
import { randomUUID } from "node:crypto";
import { copyFile, unlink } from "node:fs/promises";
import { isAbsolute, parse, resolve } from "node:path";
import { copyFile, rm } from "node:fs/promises";
import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { EventEmitter } from "node:events";
import type { TaskStore } from "./store.js";
@@ -32,6 +31,7 @@ import { createLogger } from "./logger.js";
// Minimum Fusion version for plugin compatibility checks (can be expanded later)
const MINIMUM_FUSION_VERSION = "0.1.0";
const log = createLogger("plugin-loader");
let moduleImportVersion = 0;
export interface PluginLoaderOptions {
/** Plugin store for persistence */
@@ -88,8 +88,6 @@ export class PluginLoader extends EventEmitter<{
/** Cache of dynamically imported modules */
private loadedModules: Map<string, unknown> = new Map();
/** Monotonic nonce to guarantee unique cache-busting import URLs. */
private importNonce = 0;
constructor(private options: PluginLoaderOptions) {
super();
@@ -272,39 +270,28 @@ export class PluginLoader extends EventEmitter<{
return this.loadedModules.get(path)!;
}
let importPath = path;
let tempPath: string | null = null;
// Dynamic import - normalize to file URL so query params are honored
// consistently across Node + Vitest environments.
const moduleUrl = pathToFileURL(path).href;
let mod: unknown;
if (bypassCache) {
const parsed = parse(path);
tempPath = resolve(
parsed.dir,
`${parsed.name}.fusion-import-${process.pid}-${++this.importNonce}-${randomUUID()}${parsed.ext || ".js"}`,
);
await copyFile(path, tempPath);
importPath = tempPath;
}
const fileUrl = pathToFileURL(importPath);
// Dynamic import - use a unique search param for reload scenarios.
// Using file: URLs avoids Vite/Vitest resolver edge cases with bare
// absolute filesystem paths plus query params.
if (bypassCache) {
fileUrl.searchParams.set("t", `${Date.now()}-${this.importNonce}`);
}
try {
const mod = await import(fileUrl.href);
this.loadedModules.set(path, mod);
return mod;
} finally {
if (tempPath) {
void unlink(tempPath).catch(() => {
// Best-effort cleanup; a stale temp import file is non-fatal.
});
moduleImportVersion += 1;
const ext = extname(path);
const baseName = basename(path, ext);
const reloadedPath = resolve(dirname(path), `.${baseName}.reload-${moduleImportVersion}${ext}`);
await copyFile(path, reloadedPath);
try {
mod = await import(pathToFileURL(reloadedPath).href);
} finally {
await rm(reloadedPath, { force: true }).catch(() => undefined);
}
} else {
mod = await import(moduleUrl);
}
this.loadedModules.set(path, mod);
return mod;
}
/**

View File

@@ -479,6 +479,8 @@ describe("agent-generation module", () => {
context: expect.objectContaining({
cleanedSessions: 1,
cleanedRateLimits: 2,
ttlMs: 30 * 60 * 1000,
rateLimitWindowMs: 60 * 60 * 1000,
operation: "cleanup-expired",
}),
});

View File

@@ -215,6 +215,8 @@ function cleanupExpiredSessions(): void {
diagnostics.info("Cleanup completed", {
cleanedSessions,
cleanedRateLimits,
ttlMs: SESSION_TTL_MS,
rateLimitWindowMs: RATE_LIMIT_WINDOW_MS,
operation: "cleanup-expired",
});
}

View File

@@ -153,6 +153,8 @@ describe("AiSessionStore", () => {
terminalDeleted: 2,
orphanedDeleted: 1,
totalDeleted: 3,
maxAgeMs: SESSION_CLEANUP_DEFAULT_MAX_AGE_MS,
operation: "cleanup-stale-sessions",
}),
}),
);
@@ -228,6 +230,7 @@ describe("AiSessionStore", () => {
message: "Scheduled cleanup failed",
context: expect.objectContaining({
ttlMs: 60_000,
operation: "scheduled-cleanup",
error: expect.objectContaining({ message: "boom" }),
}),
}),
@@ -282,7 +285,10 @@ describe("AiSessionStore", () => {
level: "info",
scope: "ai-session-store",
message: "Recovered stale sessions after restart",
context: expect.objectContaining({ recovered: 2 }),
context: expect.objectContaining({
recovered: 2,
operation: "recover-stale-sessions",
}),
}),
);
});

View File

@@ -406,7 +406,10 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
recovered += Number(withoutQuestion.changes ?? 0);
if (recovered > 0) {
diagnostics.info("Recovered stale sessions after restart", { recovered });
diagnostics.info("Recovered stale sessions after restart", {
recovered,
operation: "recover-stale-sessions",
});
}
return recovered;
}
@@ -478,6 +481,8 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
terminalDeleted,
orphanedDeleted,
totalDeleted,
maxAgeMs,
operation: "cleanup-stale-sessions",
});
return {
@@ -497,7 +502,10 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
try {
this.cleanupStaleSessions(ttlMs);
} catch (error) {
diagnostics.errorFromException("Scheduled cleanup failed", error, { ttlMs });
diagnostics.errorFromException("Scheduled cleanup failed", error, {
ttlMs,
operation: "scheduled-cleanup",
});
}
};

View File

@@ -20,6 +20,9 @@ import { resolve } from "node:path";
/**
* List of AI-session modules that must use the shared diagnostics helper.
* These modules handle AI-session flows and must not use raw console.* diagnostics.
*
* Keep `agent-generation.ts` and `ai-session-store.ts` in this list — they are
* long-lived generation/cleanup surfaces with historical raw-console drift.
*/
const AI_SESSION_FLOW_MODULES = [
"planning.ts",
@@ -75,6 +78,11 @@ function findRawConsoleCalls(
}
describe("AI-Session Diagnostics Guardrail", () => {
it("explicitly guards agent-generation and ai-session-store modules", () => {
expect(AI_SESSION_FLOW_MODULES).toContain("agent-generation.ts");
expect(AI_SESSION_FLOW_MODULES).toContain("ai-session-store.ts");
});
/**
* Test that each AI-session flow module uses the shared diagnostics helper
* instead of raw console.* calls.