feat(FN-2245): enforce shared diagnostics in planning interview flows
- Migrate mission, milestone-slice, and subtask breakdown flows to shared ai-session-diagnostics logging - Add a planning-flow guardrail test that fails on raw console.* diagnostics across planning modules - Update mission and subtask breakdown tests to use structured diagnostics sink hooks instead of console spies - Keep backward-compatible diagnostics test hooks and reset injected sinks to avoid cross-test leakage
This commit is contained in:
@@ -24,6 +24,11 @@ import {
|
||||
extractJsonCandidate,
|
||||
repairJson,
|
||||
} from "./mission-interview.js";
|
||||
import {
|
||||
createSessionDiagnostics,
|
||||
resetDiagnosticsSink,
|
||||
nonfatal,
|
||||
} from "./ai-session-diagnostics.js";
|
||||
|
||||
// Re-export JSON parsing utilities from mission-interview for external consumers
|
||||
export {
|
||||
@@ -32,6 +37,13 @@ export {
|
||||
repairJson,
|
||||
} from "./mission-interview.js";
|
||||
|
||||
/**
|
||||
* Shared diagnostics helper for the milestone-slice-interview module.
|
||||
* Uses the shared ai-session-diagnostics helper for consistent scoped logging.
|
||||
* @see ai-session-diagnostics.ts for the shared contract
|
||||
*/
|
||||
const diagnostics = createSessionDiagnostics("milestone-slice-interview");
|
||||
|
||||
/**
|
||||
* Parse a target interview response (milestone or slice) from the AI agent.
|
||||
* Validates the response structure and extracts the typed data.
|
||||
@@ -40,7 +52,7 @@ function parseTargetInterviewResponseImpl(text: string): TargetInterviewResponse
|
||||
const candidate = extractJsonCandidate(text);
|
||||
|
||||
if (!candidate) {
|
||||
console.error("[milestone-slice-interview] No JSON candidate found in agent response:", text.slice(0, 500));
|
||||
diagnostics.error("No JSON candidate found in agent response", { inputSnippet: text.slice(0, 500), operation: "parse-json" });
|
||||
throw new Error("AI returned no valid JSON. Please try again.");
|
||||
}
|
||||
|
||||
@@ -52,7 +64,7 @@ function parseTargetInterviewResponseImpl(text: string): TargetInterviewResponse
|
||||
const repaired = repairJson(candidate);
|
||||
parsed = JSON.parse(repaired);
|
||||
} catch (repairErr) {
|
||||
console.error("[milestone-slice-interview] Failed to parse agent response:", candidate.slice(0, 500));
|
||||
diagnostics.error("Failed to parse agent response (repair also failed)", { inputSnippet: candidate.slice(0, 500), operation: "parse-json-repair" });
|
||||
throw new Error(
|
||||
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
|
||||
);
|
||||
@@ -75,7 +87,7 @@ function parseTargetInterviewResponseImpl(text: string): TargetInterviewResponse
|
||||
}
|
||||
}
|
||||
|
||||
console.error("[milestone-slice-interview] Invalid response structure:", JSON.stringify(parsed).slice(0, 500));
|
||||
diagnostics.error("Invalid response structure from AI", { parsedSnippet: JSON.stringify(parsed).slice(0, 500), operation: "parse-validate" });
|
||||
throw new Error("AI returned an invalid response structure. Please try again.");
|
||||
}
|
||||
|
||||
@@ -461,7 +473,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
|
||||
(row) => row.type === "milestone_interview" || row.type === "slice_interview"
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[milestone-slice-interview] Failed to list recoverable sessions:", error);
|
||||
diagnostics.errorFromException("Failed to list recoverable sessions", error, { operation: "list-recoverable" });
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -472,7 +484,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
|
||||
sessions.set(session.id, session);
|
||||
rehydrated += 1;
|
||||
} catch (error) {
|
||||
console.error(`[milestone-slice-interview] Failed to rehydrate session ${row.id}:`, error);
|
||||
diagnostics.errorFromException("Failed to rehydrate session", error, { sessionId: row.id, operation: "rehydrate" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,11 +553,12 @@ export class MilestoneSliceInterviewStreamManager extends EventEmitter {
|
||||
if (!callbacks) return eventId;
|
||||
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
callback(event, eventId);
|
||||
} catch (err) {
|
||||
console.error(`[milestone-slice-interview] Error broadcasting to client for session ${sessionId}:`, err);
|
||||
}
|
||||
nonfatal(
|
||||
() => callback(event, eventId),
|
||||
diagnostics,
|
||||
"Error broadcasting to client",
|
||||
{ sessionId, operation: "broadcast" }
|
||||
);
|
||||
}
|
||||
|
||||
return eventId;
|
||||
@@ -657,11 +670,12 @@ function disposeAgentForRetry(session: TargetInterviewSession): void {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
session.agent.session.dispose?.();
|
||||
} catch (error) {
|
||||
console.error(`[milestone-slice-interview] Error disposing agent for retry in session ${session.id}:`, error);
|
||||
}
|
||||
nonfatal(
|
||||
() => session.agent.session.dispose?.(),
|
||||
diagnostics,
|
||||
"Error disposing agent for retry",
|
||||
{ sessionId: session.id, operation: "dispose-retry" }
|
||||
);
|
||||
|
||||
session.agent = undefined;
|
||||
}
|
||||
@@ -770,7 +784,7 @@ async function initializeAgent(session: TargetInterviewSession, rootDir: string)
|
||||
);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to initialize AI agent";
|
||||
console.error(`[milestone-slice-interview] Agent initialization error for session ${session.id}:`, err);
|
||||
diagnostics.errorFromException("Agent initialization error for session", err, { sessionId: session.id, operation: "initialize-agent" });
|
||||
session.error = errorMessage;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "error", errorMessage);
|
||||
@@ -828,8 +842,9 @@ async function continueAgentConversation(session: TargetInterviewSession, messag
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
if (attempt < MAX_PARSE_RETRIES) {
|
||||
console.warn(
|
||||
`[milestone-slice-interview] Parse attempt ${attempt + 1} failed for session ${session.id}, requesting reformat`
|
||||
diagnostics.warn(
|
||||
"Parse attempt failed, requesting reformat",
|
||||
{ sessionId: session.id, attempt: attempt + 1, operation: "parse-retry" }
|
||||
);
|
||||
try {
|
||||
session.thinkingOutput = "";
|
||||
@@ -857,7 +872,7 @@ async function continueAgentConversation(session: TargetInterviewSession, messag
|
||||
}
|
||||
responseText = retryText;
|
||||
} catch (retryErr) {
|
||||
console.error(`[milestone-slice-interview] Retry prompt failed for session ${session.id}:`, retryErr);
|
||||
diagnostics.errorFromException("Retry prompt failed for session", retryErr, { sessionId: session.id, operation: "retry-prompt" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -866,7 +881,10 @@ async function continueAgentConversation(session: TargetInterviewSession, messag
|
||||
|
||||
if (!parsed) {
|
||||
const errorMsg = `${lastError?.message || "Failed to parse AI response"} You can try responding again or start a new session.`;
|
||||
console.error(`[milestone-slice-interview] All parse attempts exhausted for session ${session.id}:`, errorMsg);
|
||||
diagnostics.error(
|
||||
"All parse attempts exhausted for session",
|
||||
{ sessionId: session.id, message: errorMsg, operation: "parse-exhausted" }
|
||||
);
|
||||
session.error = errorMsg;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "error", errorMsg);
|
||||
@@ -901,7 +919,7 @@ async function continueAgentConversation(session: TargetInterviewSession, messag
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
|
||||
console.error(`[milestone-slice-interview] Agent conversation error for session ${session.id}:`, err);
|
||||
diagnostics.errorFromException("Agent conversation error for session", err, { sessionId: session.id, operation: "conversation" });
|
||||
session.error = errorMessage;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "error", errorMessage);
|
||||
@@ -955,7 +973,7 @@ export async function createTargetInterviewSession(
|
||||
|
||||
// Initialize AI agent in background
|
||||
initializeAgent(session, rootDir).catch((err) => {
|
||||
console.error(`[milestone-slice-interview] Failed to initialize agent for session ${sessionId}:`, err);
|
||||
diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" });
|
||||
persistSession(session, "error", err.message || "Failed to initialize AI agent");
|
||||
milestoneSliceInterviewStreamManager.broadcast(sessionId, {
|
||||
type: "error",
|
||||
@@ -1104,7 +1122,7 @@ export function getTargetInterviewSession(sessionId: string): TargetInterviewSes
|
||||
sessions.set(restored.id, restored);
|
||||
return restored;
|
||||
} catch (error) {
|
||||
console.error(`[milestone-slice-interview] Failed to restore session ${sessionId} from SQLite:`, error);
|
||||
diagnostics.errorFromException("Failed to restore session from SQLite", error, { sessionId, operation: "restore" });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1267,4 +1285,7 @@ export function __resetMilestoneSliceInterviewState(): void {
|
||||
}
|
||||
_aiSessionDeletedListener = undefined;
|
||||
_aiSessionStore = undefined;
|
||||
|
||||
// Reset diagnostics sink to default
|
||||
resetDiagnosticsSink();
|
||||
}
|
||||
|
||||
@@ -28,9 +28,12 @@ import {
|
||||
RateLimitError,
|
||||
SessionNotFoundError,
|
||||
submitMissionInterviewResponse,
|
||||
__getMissionInterviewDiagnostics,
|
||||
__setMissionInterviewDiagnostics,
|
||||
} from "./mission-interview.js";
|
||||
import {
|
||||
setDiagnosticsSink,
|
||||
resetDiagnosticsSink,
|
||||
} from "./ai-session-diagnostics.js";
|
||||
import type { LogEntry } from "./ai-session-diagnostics.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { AiSessionRow } from "./ai-session-store.js";
|
||||
|
||||
@@ -257,13 +260,10 @@ describe("mission-interview module", () => {
|
||||
store.rows.set(goodRow.id, goodRow);
|
||||
store.rows.set(badRow.id, badRow);
|
||||
|
||||
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
|
||||
__setMissionInterviewDiagnostics({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
loggedErrors.push({ message, args });
|
||||
},
|
||||
// Capture structured diagnostics via shared helper sink
|
||||
const loggedEntries: LogEntry[] = [];
|
||||
setDiagnosticsSink((level, scope, message, context) => {
|
||||
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
||||
});
|
||||
|
||||
const rehydrated = rehydrateFromStore(store as any);
|
||||
@@ -271,10 +271,20 @@ describe("mission-interview module", () => {
|
||||
expect(rehydrated).toBe(1);
|
||||
expect(getMissionInterviewSession(goodRow.id)).toBeDefined();
|
||||
expect(getMissionInterviewSession(badRow.id)).toBeUndefined();
|
||||
expect(loggedErrors).toContainEqual({
|
||||
message: `Failed to rehydrate session ${badRow.id}:`,
|
||||
args: [expect.any(Error)],
|
||||
});
|
||||
// Assert structured diagnostic record
|
||||
expect(loggedEntries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: "error",
|
||||
scope: "mission-interview",
|
||||
message: "Failed to rehydrate session",
|
||||
context: expect.objectContaining({
|
||||
sessionId: badRow.id,
|
||||
operation: "rehydrate",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
resetDiagnosticsSink();
|
||||
});
|
||||
|
||||
it("falls through to SQLite when in-memory session is missing", () => {
|
||||
@@ -531,13 +541,10 @@ describe("mission-interview module", () => {
|
||||
});
|
||||
|
||||
it("logs error diagnostic when broadcast callback throws", () => {
|
||||
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
|
||||
__setMissionInterviewDiagnostics({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
loggedErrors.push({ message, args });
|
||||
},
|
||||
// Capture structured diagnostics via shared helper sink
|
||||
const loggedEntries: LogEntry[] = [];
|
||||
setDiagnosticsSink((level, scope, message, context) => {
|
||||
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
||||
});
|
||||
|
||||
const throwingCallback = vi.fn(() => {
|
||||
@@ -551,10 +558,20 @@ describe("mission-interview module", () => {
|
||||
).not.toThrow();
|
||||
|
||||
expect(throwingCallback).toHaveBeenCalledTimes(1);
|
||||
expect(loggedErrors).toContainEqual({
|
||||
message: "Error broadcasting to client for session session-error-callback:",
|
||||
args: [expect.any(Error)],
|
||||
});
|
||||
// Assert structured diagnostic record
|
||||
expect(loggedEntries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: "error",
|
||||
scope: "mission-interview",
|
||||
message: "Error broadcasting to client",
|
||||
context: expect.objectContaining({
|
||||
sessionId: "session-error-callback",
|
||||
operation: "broadcast",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
resetDiagnosticsSink();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -595,61 +612,79 @@ describe("mission-interview module", () => {
|
||||
});
|
||||
|
||||
it("logs error diagnostic when no JSON candidate found before throwing", () => {
|
||||
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
|
||||
__setMissionInterviewDiagnostics({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
loggedErrors.push({ message, args });
|
||||
},
|
||||
// Capture structured diagnostics via shared helper sink
|
||||
const loggedEntries: LogEntry[] = [];
|
||||
setDiagnosticsSink((level, scope, message, context) => {
|
||||
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
||||
});
|
||||
|
||||
const input = "I'm not sure what to ask about this project.";
|
||||
expect(() => parseMissionAgentResponse(input)).toThrow("no valid JSON");
|
||||
|
||||
expect(loggedErrors).toContainEqual({
|
||||
message: "No JSON candidate found in agent response:",
|
||||
args: [expect.stringContaining("I'm not sure")],
|
||||
});
|
||||
expect(loggedEntries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: "error",
|
||||
scope: "mission-interview",
|
||||
message: "No JSON candidate found in agent response",
|
||||
context: expect.objectContaining({
|
||||
inputSnippet: expect.stringContaining("I'm not sure"),
|
||||
operation: "parse-json",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
resetDiagnosticsSink();
|
||||
});
|
||||
|
||||
it("logs error diagnostic when repair also fails before throwing", () => {
|
||||
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
|
||||
__setMissionInterviewDiagnostics({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
loggedErrors.push({ message, args });
|
||||
},
|
||||
// Capture structured diagnostics via shared helper sink
|
||||
const loggedEntries: LogEntry[] = [];
|
||||
setDiagnosticsSink((level, scope, message, context) => {
|
||||
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
||||
});
|
||||
|
||||
// Invalid JSON that repair cannot fix
|
||||
const input = '{"type":"question","data":{"id":q-1,"question":"What is this?"}';
|
||||
expect(() => parseMissionAgentResponse(input)).toThrow("Failed to parse AI response");
|
||||
|
||||
expect(loggedErrors).toContainEqual({
|
||||
message: "Failed to parse agent response:",
|
||||
args: [expect.stringContaining('{"type":"question"')],
|
||||
});
|
||||
expect(loggedEntries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: "error",
|
||||
scope: "mission-interview",
|
||||
message: "Failed to parse agent response (repair also failed)",
|
||||
context: expect.objectContaining({
|
||||
inputSnippet: expect.stringContaining('{"type":"question"'),
|
||||
operation: "parse-json-repair",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
resetDiagnosticsSink();
|
||||
});
|
||||
|
||||
it("logs error diagnostic for invalid response structure before throwing", () => {
|
||||
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
|
||||
__setMissionInterviewDiagnostics({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
loggedErrors.push({ message, args });
|
||||
},
|
||||
// Capture structured diagnostics via shared helper sink
|
||||
const loggedEntries: LogEntry[] = [];
|
||||
setDiagnosticsSink((level, scope, message, context) => {
|
||||
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
||||
});
|
||||
|
||||
const input = '{"type":"unknown","data":null}';
|
||||
expect(() => parseMissionAgentResponse(input)).toThrow("invalid response structure");
|
||||
|
||||
expect(loggedErrors).toContainEqual({
|
||||
message: "Invalid response structure:",
|
||||
args: [expect.stringContaining('"type":"unknown"')],
|
||||
});
|
||||
expect(loggedEntries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: "error",
|
||||
scope: "mission-interview",
|
||||
message: "Invalid response structure from AI",
|
||||
context: expect.objectContaining({
|
||||
parsedSnippet: expect.stringContaining('"type":"unknown"'),
|
||||
operation: "parse-validate",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
resetDiagnosticsSink();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
|
||||
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
|
||||
import {
|
||||
createSessionDiagnostics,
|
||||
resetDiagnosticsSink,
|
||||
nonfatal,
|
||||
} from "./ai-session-diagnostics.js";
|
||||
|
||||
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -29,65 +34,36 @@ type AgentResult = any;
|
||||
let createFnAgent: any;
|
||||
|
||||
/**
|
||||
* Diagnostics logger for the mission-interview module.
|
||||
* Provides consistent [mission-interview] prefixed output with test-injectable handlers.
|
||||
* Mirrors the pattern established in planning.ts (FN-2225).
|
||||
* Shared diagnostics helper for the mission-interview module.
|
||||
* Uses the shared ai-session-diagnostics helper for consistent scoped logging.
|
||||
* @see ai-session-diagnostics.ts for the shared contract
|
||||
*/
|
||||
interface DiagnosticsLogger {
|
||||
log(message: string, ...args: unknown[]): void;
|
||||
warn(message: string, ...args: unknown[]): void;
|
||||
error(message: string, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
const defaultDiagnostics: DiagnosticsLogger = {
|
||||
log(message: string, ...args: unknown[]) {
|
||||
console.log(`[mission-interview] ${message}`, ...args);
|
||||
},
|
||||
warn(message: string, ...args: unknown[]) {
|
||||
console.warn(`[mission-interview] ${message}`, ...args);
|
||||
},
|
||||
error(message: string, ...args: unknown[]) {
|
||||
console.error(`[mission-interview] ${message}`, ...args);
|
||||
},
|
||||
};
|
||||
|
||||
let _diagnostics: DiagnosticsLogger = defaultDiagnostics;
|
||||
const diagnostics = createSessionDiagnostics("mission-interview");
|
||||
|
||||
/**
|
||||
* Get the current diagnostics logger.
|
||||
* Get the current diagnostics logger (for backward compatibility).
|
||||
* @internal - exposed for test hook
|
||||
*/
|
||||
export function __getMissionInterviewDiagnostics(): DiagnosticsLogger {
|
||||
return _diagnostics;
|
||||
export function __getMissionInterviewDiagnostics() {
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a diagnostics logger (test-only).
|
||||
* When a logger is injected, all mission-interview module diagnostics route through it.
|
||||
* Inject a diagnostics sink (test-only).
|
||||
* Delegates to the shared ai-session-diagnostics sink.
|
||||
* When a sink is injected, all mission-interview module diagnostics route through it.
|
||||
* This allows tests to assert on diagnostics without global console spies.
|
||||
* @internal - exposed for test hook
|
||||
*/
|
||||
export function __setMissionInterviewDiagnostics(diagnostics: DiagnosticsLogger | null): void {
|
||||
_diagnostics = diagnostics ?? defaultDiagnostics;
|
||||
export function __setMissionInterviewDiagnostics(_logger: unknown): void {
|
||||
// For backward compatibility, we keep this function but it now delegates
|
||||
// to the shared helper's sink mechanism. The actual sink injection
|
||||
// should use setDiagnosticsSink() from ai-session-diagnostics.
|
||||
if (_logger === null) {
|
||||
resetDiagnosticsSink();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared diagnostics helper used throughout the mission-interview module.
|
||||
* Routes all informational, warning, and error diagnostics through the current logger.
|
||||
* Mirrors the pattern from planning.ts (FN-2225).
|
||||
*/
|
||||
const diagnostics: DiagnosticsLogger = {
|
||||
log(message: string, ...args: unknown[]) {
|
||||
_diagnostics.log(message, ...args);
|
||||
},
|
||||
warn(message: string, ...args: unknown[]) {
|
||||
_diagnostics.warn(message, ...args);
|
||||
},
|
||||
error(message: string, ...args: unknown[]) {
|
||||
_diagnostics.error(message, ...args);
|
||||
},
|
||||
};
|
||||
|
||||
async function initEngine() {
|
||||
if (!createFnAgent) {
|
||||
try {
|
||||
@@ -422,7 +398,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
|
||||
try {
|
||||
rows = store.listRecoverable().filter((row) => row.type === "mission_interview");
|
||||
} catch (error) {
|
||||
diagnostics.error("Failed to list recoverable sessions:", error);
|
||||
diagnostics.errorFromException("Failed to list recoverable sessions", error, { operation: "list-recoverable" });
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -433,7 +409,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
|
||||
sessions.set(session.id, session);
|
||||
rehydrated += 1;
|
||||
} catch (error) {
|
||||
diagnostics.error(`Failed to rehydrate session ${row.id}:`, error);
|
||||
diagnostics.errorFromException("Failed to rehydrate session", error, { sessionId: row.id, operation: "rehydrate" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,11 +478,12 @@ export class MissionInterviewStreamManager extends EventEmitter {
|
||||
if (!callbacks) return eventId;
|
||||
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
callback(event, eventId);
|
||||
} catch (err) {
|
||||
diagnostics.error(`Error broadcasting to client for session ${sessionId}:`, err);
|
||||
}
|
||||
nonfatal(
|
||||
() => callback(event, eventId),
|
||||
diagnostics,
|
||||
"Error broadcasting to client",
|
||||
{ sessionId, operation: "broadcast" }
|
||||
);
|
||||
}
|
||||
|
||||
return eventId;
|
||||
@@ -676,7 +653,7 @@ export function parseMissionAgentResponse(text: string): MissionInterviewRespons
|
||||
const candidate = extractJsonCandidate(text);
|
||||
|
||||
if (!candidate) {
|
||||
diagnostics.error("No JSON candidate found in agent response:", text.slice(0, 500));
|
||||
diagnostics.error("No JSON candidate found in agent response", { inputSnippet: text.slice(0, 500), operation: "parse-json" });
|
||||
throw new Error("AI returned no valid JSON. Please try again.");
|
||||
}
|
||||
|
||||
@@ -688,7 +665,7 @@ export function parseMissionAgentResponse(text: string): MissionInterviewRespons
|
||||
const repaired = repairJson(candidate);
|
||||
parsed = JSON.parse(repaired);
|
||||
} catch (repairErr) {
|
||||
diagnostics.error("Failed to parse agent response:", candidate.slice(0, 500));
|
||||
diagnostics.error("Failed to parse agent response (repair also failed)", { inputSnippet: candidate.slice(0, 500), operation: "parse-json-repair" });
|
||||
throw new Error(
|
||||
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
|
||||
);
|
||||
@@ -713,7 +690,7 @@ export function parseMissionAgentResponse(text: string): MissionInterviewRespons
|
||||
}
|
||||
}
|
||||
|
||||
diagnostics.error("Invalid response structure:", JSON.stringify(parsed).slice(0, 500));
|
||||
diagnostics.error("Invalid response structure from AI", { parsedSnippet: JSON.stringify(parsed).slice(0, 500), operation: "parse-validate" });
|
||||
throw new Error("AI returned an invalid response structure. Please try again.");
|
||||
}
|
||||
|
||||
@@ -768,11 +745,12 @@ function disposeMissionAgentForRetry(session: MissionInterviewSession): void {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
session.agent.session.dispose?.();
|
||||
} catch (error) {
|
||||
diagnostics.error(`Error disposing agent for retry in session ${session.id}:`, error);
|
||||
}
|
||||
nonfatal(
|
||||
() => session.agent.session.dispose?.(),
|
||||
diagnostics,
|
||||
"Error disposing agent for retry",
|
||||
{ sessionId: session.id, operation: "dispose-retry" }
|
||||
);
|
||||
|
||||
session.agent = undefined;
|
||||
}
|
||||
@@ -798,7 +776,7 @@ async function initializeAgent(
|
||||
);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to initialize AI agent";
|
||||
diagnostics.error(`Agent initialization error for session ${session.id}:`, err);
|
||||
diagnostics.errorFromException("Agent initialization error for session", err, { sessionId: session.id, operation: "initialize-agent" });
|
||||
session.error = errorMessage;
|
||||
session.updatedAt = new Date();
|
||||
persistMissionSession(session, "error", errorMessage);
|
||||
@@ -952,7 +930,8 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
|
||||
|
||||
if (attempt < MAX_PARSE_RETRIES) {
|
||||
diagnostics.warn(
|
||||
`Parse attempt ${attempt + 1} failed for session ${session.id}, requesting reformat`
|
||||
"Parse attempt failed, requesting reformat",
|
||||
{ sessionId: session.id, attempt: attempt + 1, operation: "parse-retry" }
|
||||
);
|
||||
try {
|
||||
session.thinkingOutput = "";
|
||||
@@ -980,7 +959,7 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
|
||||
}
|
||||
responseText = retryText;
|
||||
} catch (retryErr) {
|
||||
diagnostics.error(`Retry prompt failed for session ${session.id}:`, retryErr);
|
||||
diagnostics.errorFromException("Retry prompt failed for session", retryErr, { sessionId: session.id, operation: "retry-prompt" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -989,7 +968,10 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
|
||||
|
||||
if (!parsed) {
|
||||
const errorMsg = `${lastError?.message || "Failed to parse AI response"} You can try responding again or start a new session.`;
|
||||
diagnostics.error(`All parse attempts exhausted for session ${session.id}:`, errorMsg);
|
||||
diagnostics.error(
|
||||
"All parse attempts exhausted for session",
|
||||
{ sessionId: session.id, message: errorMsg, operation: "parse-exhausted" }
|
||||
);
|
||||
session.error = errorMsg;
|
||||
session.updatedAt = new Date();
|
||||
persistMissionSession(session, "error", errorMsg);
|
||||
@@ -1024,7 +1006,7 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
|
||||
diagnostics.error(`Agent conversation error for session ${session.id}:`, err);
|
||||
diagnostics.errorFromException("Agent conversation error for session", err, { sessionId: session.id, operation: "conversation" });
|
||||
session.error = errorMessage;
|
||||
session.updatedAt = new Date();
|
||||
persistMissionSession(session, "error", errorMessage);
|
||||
@@ -1078,7 +1060,7 @@ export async function createMissionInterviewSession(
|
||||
|
||||
// Initialize AI agent in background
|
||||
initializeAgent(session, rootDir, promptOverrides).catch((err) => {
|
||||
diagnostics.error(`Failed to initialize agent for session ${sessionId}:`, err);
|
||||
diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" });
|
||||
persistMissionSession(session, "error", err.message || "Failed to initialize AI agent");
|
||||
missionInterviewStreamManager.broadcast(sessionId, {
|
||||
type: "error",
|
||||
@@ -1219,7 +1201,7 @@ export function getMissionInterviewSession(sessionId: string): MissionInterviewS
|
||||
sessions.set(restored.id, restored);
|
||||
return restored;
|
||||
} catch (error) {
|
||||
diagnostics.error(`Failed to restore session ${sessionId} from SQLite:`, error);
|
||||
diagnostics.errorFromException("Failed to restore session from SQLite", error, { sessionId, operation: "restore" });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1250,8 +1232,8 @@ export function __resetMissionInterviewState(): void {
|
||||
_aiSessionDeletedListener = undefined;
|
||||
_aiSessionStore = undefined;
|
||||
|
||||
// Reset diagnostics logger to default
|
||||
__setMissionInterviewDiagnostics(null);
|
||||
// Reset diagnostics sink to default
|
||||
resetDiagnosticsSink();
|
||||
}
|
||||
|
||||
// ── Custom Errors ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Planning-Flow Diagnostics Guardrail Test
|
||||
*
|
||||
* This test enforces that AI-session planning flow modules use the shared
|
||||
* ai-session-diagnostics helper instead of raw console.* calls for diagnostics.
|
||||
*
|
||||
* Guardrail: These modules must NOT contain direct console.log( / console.warn( / console.error(
|
||||
* calls in AI-session flow code. Raw console diagnostics indicate incomplete migration
|
||||
* to the shared helper or accidental reintroduction.
|
||||
*
|
||||
* @see ai-session-diagnostics.ts for the shared diagnostics contract
|
||||
*/
|
||||
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* List of planning-flow modules that must use the shared diagnostics helper.
|
||||
* These modules handle AI-session flows and must not use raw console.* diagnostics.
|
||||
*/
|
||||
const PLANNING_FLOW_MODULES = [
|
||||
"planning.ts",
|
||||
"mission-interview.ts",
|
||||
"milestone-slice-interview.ts",
|
||||
"subtask-breakdown.ts",
|
||||
] as const;
|
||||
|
||||
type PlanningFlowModule = (typeof PLANNING_FLOW_MODULES)[number];
|
||||
|
||||
/**
|
||||
* Patterns that indicate raw console diagnostics in AI-session failure paths.
|
||||
* These should be caught by the guardrail.
|
||||
* Note: Patterns must have global flag (g) for use with matchAll.
|
||||
*/
|
||||
const RAW_CONSOLE_PATTERNS: readonly RegExp[] = [
|
||||
/console\.log\(/g,
|
||||
/console\.warn\(/g,
|
||||
/console\.error\(/g,
|
||||
];
|
||||
|
||||
type RawConsolePattern = (typeof RAW_CONSOLE_PATTERNS)[number];
|
||||
|
||||
/**
|
||||
* Read the source content of a planning-flow module.
|
||||
* Throws if the file cannot be read.
|
||||
*/
|
||||
function readModuleSource(moduleName: PlanningFlowModule): string {
|
||||
const modulePath = resolve(import.meta.dirname, moduleName);
|
||||
return readFileSync(modulePath, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all raw console calls in a module's source.
|
||||
* Returns an array of { pattern, match } objects for each found violation.
|
||||
*/
|
||||
function findRawConsoleCalls(
|
||||
source: string,
|
||||
patterns: readonly RegExp[]
|
||||
): Array<{ pattern: RegExp; match: RegExpMatchArray }> {
|
||||
const violations: Array<{ pattern: RegExp; match: RegExpMatchArray }> = [];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const matches = source.matchAll(pattern);
|
||||
for (const match of matches) {
|
||||
violations.push({ pattern, match });
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
describe("Planning-Flow Diagnostics Guardrail", () => {
|
||||
/**
|
||||
* Test that each planning-flow module uses the shared diagnostics helper
|
||||
* instead of raw console.* calls.
|
||||
*
|
||||
* This guardrail prevents:
|
||||
* - Incomplete migrations where raw console.* remains
|
||||
* - Accidental reintroduction of raw console diagnostics
|
||||
* - Inconsistent diagnostics across planning flow modules
|
||||
*/
|
||||
describe("AI-session failure paths must use shared diagnostics helper", () => {
|
||||
for (const moduleName of PLANNING_FLOW_MODULES) {
|
||||
const moduleShortName = moduleName.replace(".ts", "");
|
||||
|
||||
it(`${moduleShortName} does not contain raw console.* diagnostics`, () => {
|
||||
const source = readModuleSource(moduleName);
|
||||
const violations = findRawConsoleCalls(source, RAW_CONSOLE_PATTERNS);
|
||||
|
||||
if (violations.length > 0) {
|
||||
const violationDetails = violations
|
||||
.map(({ pattern, match }) => {
|
||||
// Calculate line number from match index
|
||||
const beforeMatch = source.slice(0, match.index);
|
||||
const lineNumber = beforeMatch.split("\n").length;
|
||||
return ` Line ${lineNumber}: ${match[0]}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
expect.fail(
|
||||
`${moduleName} contains raw console.* diagnostics in AI-session flow code.\n` +
|
||||
`Expected: Use createSessionDiagnostics() + diagnostics.error() from ai-session-diagnostics.js\n` +
|
||||
`Found ${violations.length} violation(s):\n` +
|
||||
`${violationDetails}\n\n` +
|
||||
`Migration guide:\n` +
|
||||
` 1. Import: import { createSessionDiagnostics } from "./ai-session-diagnostics.js";\n` +
|
||||
` 2. Create: const diagnostics = createSessionDiagnostics("${moduleShortName}");\n` +
|
||||
` 3. Replace: console.error("msg:", err) -> diagnostics.errorFromException("msg", err, { sessionId, operation });\n` +
|
||||
` 4. Replace: console.error("msg") -> diagnostics.error("msg", { sessionId, operation });`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that the shared diagnostics helper exists and exports expected APIs.
|
||||
* This ensures the guardrail itself has a valid target to enforce against.
|
||||
*/
|
||||
describe("shared diagnostics helper contract", () => {
|
||||
it("ai-session-diagnostics exports required APIs", async () => {
|
||||
const helperPath = resolve(import.meta.dirname, "ai-session-diagnostics.ts");
|
||||
const helperSource = readFileSync(helperPath, "utf-8");
|
||||
|
||||
// Verify the helper exports the core APIs
|
||||
expect(helperSource).toContain("createSessionDiagnostics");
|
||||
expect(helperSource).toContain("setDiagnosticsSink");
|
||||
expect(helperSource).toContain("resetDiagnosticsSink");
|
||||
expect(helperSource).toContain("nonfatal");
|
||||
expect(helperSource).toContain("errorFromException");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,11 @@ vi.mock("@fusion/engine", () => ({
|
||||
import type { AiSessionRow } from "./ai-session-store.js";
|
||||
// @ts-expect-error Vite raw loader import for source-level utility tests
|
||||
import subtaskBreakdownSource from "./subtask-breakdown.ts?raw";
|
||||
import {
|
||||
setDiagnosticsSink,
|
||||
resetDiagnosticsSink,
|
||||
} from "./ai-session-diagnostics.js";
|
||||
import type { LogEntry } from "./ai-session-diagnostics.js";
|
||||
import {
|
||||
__resetSubtaskBreakdownState,
|
||||
cancelSubtaskSession,
|
||||
@@ -27,8 +32,6 @@ import {
|
||||
InvalidSessionStateError,
|
||||
setAiSessionStore,
|
||||
SubtaskStreamManager,
|
||||
__getSubtaskBreakdownDiagnostics,
|
||||
__setSubtaskBreakdownDiagnostics,
|
||||
} from "./subtask-breakdown.js";
|
||||
|
||||
const UUID_REGEX =
|
||||
@@ -755,6 +758,38 @@ describe("subtask session rehydration", () => {
|
||||
expect(getSubtaskSession(planningRow.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns 0 and logs diagnostic when listRecoverable throws", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
// Override listRecoverable to throw
|
||||
vi.spyOn(store, "listRecoverable").mockImplementation(() => {
|
||||
throw new Error("Database connection failed");
|
||||
});
|
||||
|
||||
// Capture structured diagnostics via shared helper sink
|
||||
const loggedEntries: LogEntry[] = [];
|
||||
setDiagnosticsSink((level, scope, message, context) => {
|
||||
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
||||
});
|
||||
|
||||
const rehydrated = rehydrateFromStore(store as any);
|
||||
|
||||
// Non-fatal: returns 0 and continues
|
||||
expect(rehydrated).toBe(0);
|
||||
// Assert structured diagnostic record
|
||||
expect(loggedEntries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: "error",
|
||||
scope: "subtask-breakdown",
|
||||
message: "Failed to list recoverable sessions",
|
||||
context: expect.objectContaining({
|
||||
operation: "list-recoverable",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
resetDiagnosticsSink();
|
||||
});
|
||||
|
||||
it("skips corrupted rows and continues with valid rows", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const goodRow = buildSubtaskRow({ id: "subtask-good", status: "generating" });
|
||||
@@ -767,13 +802,10 @@ describe("subtask session rehydration", () => {
|
||||
store.rows.set(goodRow.id, goodRow);
|
||||
store.rows.set(badRow.id, badRow);
|
||||
|
||||
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
|
||||
__setSubtaskBreakdownDiagnostics({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
loggedErrors.push({ message, args });
|
||||
},
|
||||
// Capture structured diagnostics via shared helper sink
|
||||
const loggedEntries: LogEntry[] = [];
|
||||
setDiagnosticsSink((level, scope, message, context) => {
|
||||
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
||||
});
|
||||
|
||||
const rehydrated = rehydrateFromStore(store as any);
|
||||
@@ -781,10 +813,20 @@ describe("subtask session rehydration", () => {
|
||||
expect(rehydrated).toBe(1);
|
||||
expect(getSubtaskSession(goodRow.id)).toBeDefined();
|
||||
expect(getSubtaskSession(badRow.id)).toBeUndefined();
|
||||
expect(loggedErrors).toContainEqual({
|
||||
message: `Failed to rehydrate session ${badRow.id}:`,
|
||||
args: [expect.any(Error)],
|
||||
});
|
||||
// Assert structured diagnostic record
|
||||
expect(loggedEntries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: "error",
|
||||
scope: "subtask-breakdown",
|
||||
message: "Failed to rehydrate session",
|
||||
context: expect.objectContaining({
|
||||
sessionId: badRow.id,
|
||||
operation: "rehydrate",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
resetDiagnosticsSink();
|
||||
});
|
||||
|
||||
it("falls through to SQLite when session is missing in memory", () => {
|
||||
@@ -836,22 +878,29 @@ describe("subtask session rehydration", () => {
|
||||
store.rows.set(badRow.id, badRow);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
|
||||
__setSubtaskBreakdownDiagnostics({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
loggedErrors.push({ message, args });
|
||||
},
|
||||
// Capture structured diagnostics via shared helper sink
|
||||
const loggedEntries: LogEntry[] = [];
|
||||
setDiagnosticsSink((level, scope, message, context) => {
|
||||
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
||||
});
|
||||
|
||||
const session = getSubtaskSession(badRow.id);
|
||||
|
||||
expect(session).toBeUndefined();
|
||||
expect(loggedErrors).toContainEqual({
|
||||
message: `Failed to restore session ${badRow.id} from SQLite:`,
|
||||
args: [expect.any(Error)],
|
||||
});
|
||||
// Assert structured diagnostic record includes sessionId
|
||||
expect(loggedEntries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: "error",
|
||||
scope: "subtask-breakdown",
|
||||
message: "Failed to restore session from SQLite",
|
||||
context: expect.objectContaining({
|
||||
sessionId: badRow.id,
|
||||
operation: "restore",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
resetDiagnosticsSink();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,71 +4,47 @@ import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
|
||||
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
|
||||
import {
|
||||
createSessionDiagnostics,
|
||||
resetDiagnosticsSink,
|
||||
} from "./ai-session-diagnostics.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let createFnAgent: any;
|
||||
const engineModule = "@fusion/engine";
|
||||
|
||||
/**
|
||||
* Diagnostics logger for the subtask-breakdown module.
|
||||
* Provides consistent [subtask-breakdown] prefixed output with test-injectable handlers.
|
||||
* Mirrors the pattern established in planning.ts (FN-2225).
|
||||
* Shared diagnostics helper for the subtask-breakdown module.
|
||||
* Uses the shared ai-session-diagnostics helper for consistent scoped logging.
|
||||
* @see ai-session-diagnostics.ts for the shared contract
|
||||
*/
|
||||
interface DiagnosticsLogger {
|
||||
log(message: string, ...args: unknown[]): void;
|
||||
warn(message: string, ...args: unknown[]): void;
|
||||
error(message: string, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
const defaultDiagnostics: DiagnosticsLogger = {
|
||||
log(message: string, ...args: unknown[]) {
|
||||
console.log(`[subtask-breakdown] ${message}`, ...args);
|
||||
},
|
||||
warn(message: string, ...args: unknown[]) {
|
||||
console.warn(`[subtask-breakdown] ${message}`, ...args);
|
||||
},
|
||||
error(message: string, ...args: unknown[]) {
|
||||
console.error(`[subtask-breakdown] ${message}`, ...args);
|
||||
},
|
||||
};
|
||||
|
||||
let _diagnostics: DiagnosticsLogger = defaultDiagnostics;
|
||||
const diagnostics = createSessionDiagnostics("subtask-breakdown");
|
||||
|
||||
/**
|
||||
* Get the current diagnostics logger.
|
||||
* Get the current diagnostics logger (for backward compatibility).
|
||||
* @internal - exposed for test hook
|
||||
*/
|
||||
export function __getSubtaskBreakdownDiagnostics(): DiagnosticsLogger {
|
||||
return _diagnostics;
|
||||
export function __getSubtaskBreakdownDiagnostics() {
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a diagnostics logger (test-only).
|
||||
* When a logger is injected, all subtask-breakdown module diagnostics route through it.
|
||||
* Inject a diagnostics sink (test-only).
|
||||
* Delegates to the shared ai-session-diagnostics sink.
|
||||
* When a sink is injected, all subtask-breakdown module diagnostics route through it.
|
||||
* This allows tests to assert on diagnostics without global console spies.
|
||||
* @internal - exposed for test hook
|
||||
*/
|
||||
export function __setSubtaskBreakdownDiagnostics(diagnostics: DiagnosticsLogger | null): void {
|
||||
_diagnostics = diagnostics ?? defaultDiagnostics;
|
||||
export function __setSubtaskBreakdownDiagnostics(_logger: unknown): void {
|
||||
// For backward compatibility, we keep this function but it now delegates
|
||||
// to the shared helper's sink mechanism. The actual sink injection
|
||||
// should use setDiagnosticsSink() from ai-session-diagnostics.
|
||||
// This function is kept for backward compatibility with existing tests.
|
||||
if (_logger === null) {
|
||||
resetDiagnosticsSink();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared diagnostics helper used throughout the subtask-breakdown module.
|
||||
* Routes all informational, warning, and error diagnostics through the current logger.
|
||||
* Mirrors the pattern from planning.ts (FN-2225).
|
||||
*/
|
||||
const diagnostics: DiagnosticsLogger = {
|
||||
log(message: string, ...args: unknown[]) {
|
||||
_diagnostics.log(message, ...args);
|
||||
},
|
||||
warn(message: string, ...args: unknown[]) {
|
||||
_diagnostics.warn(message, ...args);
|
||||
},
|
||||
error(message: string, ...args: unknown[]) {
|
||||
_diagnostics.error(message, ...args);
|
||||
},
|
||||
};
|
||||
|
||||
async function initEngine() {
|
||||
if (!createFnAgent) {
|
||||
try {
|
||||
@@ -220,7 +196,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
|
||||
try {
|
||||
rows = store.listRecoverable().filter((row) => row.type === "subtask");
|
||||
} catch (error) {
|
||||
diagnostics.error("Failed to list recoverable sessions:", error);
|
||||
diagnostics.errorFromException("Failed to list recoverable sessions", error, { operation: "list-recoverable" });
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -231,7 +207,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
|
||||
sessions.set(session.sessionId, session);
|
||||
rehydrated += 1;
|
||||
} catch (error) {
|
||||
diagnostics.error(`Failed to rehydrate session ${row.id}:`, error);
|
||||
diagnostics.errorFromException("Failed to rehydrate session", error, { sessionId: row.id, operation: "rehydrate" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,7 +610,7 @@ export function getSubtaskSession(sessionId: string): SubtaskSession | undefined
|
||||
sessions.set(restored.sessionId, restored);
|
||||
return toPublicSubtaskSession(restored);
|
||||
} catch (error) {
|
||||
diagnostics.error(`Failed to restore session ${sessionId} from SQLite:`, error);
|
||||
diagnostics.errorFromException("Failed to restore session from SQLite", error, { sessionId, operation: "restore" });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -665,8 +641,8 @@ export function __resetSubtaskBreakdownState(): void {
|
||||
_aiSessionDeletedListener = undefined;
|
||||
_aiSessionStore = undefined;
|
||||
|
||||
// Reset diagnostics logger to default
|
||||
__setSubtaskBreakdownDiagnostics(null);
|
||||
// Reset diagnostics sink to default
|
||||
resetDiagnosticsSink();
|
||||
}
|
||||
|
||||
export class SessionNotFoundError extends Error {
|
||||
|
||||
Reference in New Issue
Block a user