feat(FN-1155): add AI session cleanup scheduling and API controls

- Enhance AiSessionStore cleanup logic to handle stale generating/awaiting_input sessions and remove expired records consistently
- Add a dedicated API route to trigger AI session cleanup and return cleanup results
- Wire scheduled server-side cleanup startup/shutdown behavior using configurable interval and TTL settings
- Expand dashboard tests to cover cleanup logic, route behavior, and cleanup endpoint scenarios
This commit is contained in:
gsxdsm
2026-04-08 17:26:34 -07:00
parent 2911fe7d44
commit 86e3869f75
5 changed files with 290 additions and 84 deletions

View File

@@ -4,7 +4,12 @@ import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Database } from "@fusion/core";
import { AiSessionStore, type AiSessionRow, type AiSessionStatus } from "./ai-session-store.js";
import {
AiSessionStore,
SESSION_CLEANUP_DEFAULT_MAX_AGE_MS,
type AiSessionRow,
type AiSessionStatus,
} from "./ai-session-store.js";
describe("AiSessionStore", () => {
let tmpRoot: string;
@@ -68,7 +73,7 @@ describe("AiSessionStore", () => {
}
}
it("cleanupOld removes stale sessions across all statuses and emits deleted events", () => {
it("cleanupOld removes only stale terminal sessions and emits deleted events", () => {
const deletedIds: string[] = [];
store.on("ai_session:deleted", (id) => deletedIds.push(id));
@@ -76,61 +81,68 @@ describe("AiSessionStore", () => {
seedSession({ id: "S-error", status: "error", ageMs: 2 * 60 * 60 * 1000 });
seedSession({ id: "S-generating", status: "generating", ageMs: 2 * 60 * 60 * 1000 });
seedSession({ id: "S-awaiting", status: "awaiting_input", ageMs: 2 * 60 * 60 * 1000 });
seedSession({ id: "S-fresh", status: "generating", ageMs: 5 * 60 * 1000 });
const removed = store.cleanupOld(60 * 60 * 1000);
expect(removed).toBe(4);
expect(removed).toBe(2);
expect(store.get("S-complete")).toBeNull();
expect(store.get("S-error")).toBeNull();
expect(store.get("S-generating")).toBeNull();
expect(store.get("S-awaiting")).toBeNull();
expect(store.get("S-fresh")).not.toBeNull();
expect(deletedIds.sort()).toEqual(["S-awaiting", "S-complete", "S-error", "S-generating"]);
expect(store.get("S-generating")).not.toBeNull();
expect(store.get("S-awaiting")).not.toBeNull();
expect(deletedIds.sort()).toEqual(["S-complete", "S-error"]);
});
it("cleanupOld marks stale generating/awaiting_input sessions as error before delete", () => {
db.exec(`
CREATE TABLE IF NOT EXISTS ai_session_status_audit (
id TEXT NOT NULL,
oldStatus TEXT NOT NULL,
newStatus TEXT NOT NULL,
error TEXT
);
`);
db.exec(`
CREATE TRIGGER IF NOT EXISTS trg_ai_sessions_mark_expired
AFTER UPDATE OF status ON ai_sessions
WHEN NEW.status = 'error' AND OLD.status IN ('generating', 'awaiting_input')
BEGIN
INSERT INTO ai_session_status_audit (id, oldStatus, newStatus, error)
VALUES (NEW.id, OLD.status, NEW.status, NEW.error);
END;
`);
it("cleanupStaleSessions removes stale terminal and orphaned sessions with summary", () => {
seedSession({ id: "S-complete-old", status: "complete", ageMs: 8 * 24 * 60 * 60 * 1000 });
seedSession({ id: "S-error-old", status: "error", ageMs: 8 * 24 * 60 * 60 * 1000 });
seedSession({ id: "S-generating-old", status: "generating", ageMs: 8 * 24 * 60 * 60 * 1000 });
seedSession({ id: "S-awaiting-old", status: "awaiting_input", ageMs: 8 * 24 * 60 * 60 * 1000 });
seedSession({ id: "S-generating-fresh", status: "generating", ageMs: 2 * 24 * 60 * 60 * 1000 });
seedSession({ id: "S-generating", status: "generating", ageMs: 2 * 60 * 60 * 1000, error: null });
seedSession({ id: "S-awaiting", status: "awaiting_input", ageMs: 2 * 60 * 60 * 1000, error: null });
const summary = store.cleanupStaleSessions();
store.cleanupOld(60 * 60 * 1000);
expect(summary).toEqual({
terminalDeleted: 2,
orphanedDeleted: 2,
totalDeleted: 4,
});
expect(store.get("S-complete-old")).toBeNull();
expect(store.get("S-error-old")).toBeNull();
expect(store.get("S-generating-old")).toBeNull();
expect(store.get("S-awaiting-old")).toBeNull();
expect(store.get("S-generating-fresh")).not.toBeNull();
});
const auditRows = db
.prepare("SELECT id, oldStatus, newStatus, error FROM ai_session_status_audit ORDER BY id")
.all() as Array<{ id: string; oldStatus: string; newStatus: string; error: string }>;
it("cleanupStaleSessions respects explicit maxAgeMs values", () => {
seedSession({ id: "S-complete-older", status: "complete", ageMs: 2 * 60 * 60 * 1000 });
seedSession({ id: "S-awaiting-older", status: "awaiting_input", ageMs: 2 * 60 * 60 * 1000 });
seedSession({ id: "S-error-recent", status: "error", ageMs: 30 * 60 * 1000 });
expect(auditRows).toEqual([
{
id: "S-awaiting",
oldStatus: "awaiting_input",
newStatus: "error",
error: "Session expired",
},
{
id: "S-generating",
oldStatus: "generating",
newStatus: "error",
error: "Session expired",
},
]);
const summary = store.cleanupStaleSessions(60 * 60 * 1000);
expect(summary).toEqual({
terminalDeleted: 1,
orphanedDeleted: 1,
totalDeleted: 2,
});
expect(store.get("S-complete-older")).toBeNull();
expect(store.get("S-awaiting-older")).toBeNull();
expect(store.get("S-error-recent")).not.toBeNull();
});
it("cleanupStaleSessions defaults to 7-day max age", () => {
seedSession({ id: "S-complete-6days", status: "complete", ageMs: SESSION_CLEANUP_DEFAULT_MAX_AGE_MS - 60_000 });
seedSession({ id: "S-complete-8days", status: "complete", ageMs: SESSION_CLEANUP_DEFAULT_MAX_AGE_MS + 60_000 });
const summary = store.cleanupStaleSessions();
expect(summary).toEqual({
terminalDeleted: 1,
orphanedDeleted: 0,
totalDeleted: 1,
});
expect(store.get("S-complete-6days")).not.toBeNull();
expect(store.get("S-complete-8days")).toBeNull();
});
it("startScheduledCleanup and stopScheduledCleanup control cleanup interval", () => {

View File

@@ -60,6 +60,18 @@ const MAX_THINKING_BYTES = 50 * 1024;
/** Debounce interval for thinking-only writes (ms). */
const THINKING_DEBOUNCE_MS = 2000;
/** Default max age before stale AI sessions are eligible for cleanup (7 days). */
export const SESSION_CLEANUP_DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
/** Default scheduled interval for stale session cleanup runs (6 hours). */
export const SESSION_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
export interface AiSessionCleanupSummary {
terminalDeleted: number;
orphanedDeleted: number;
totalDeleted: number;
}
// ── Store ───────────────────────────────────────────────────────────────
export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
@@ -397,10 +409,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
}
/**
* Clean up stale sessions older than the given age (ms).
*
* For stale in-progress sessions (`generating`, `awaiting_input`), status is first
* transitioned to `error` with a "Session expired" marker before deletion.
* Clean up stale terminal sessions (`complete`, `error`) older than the given age (ms).
* Returns the number of deleted sessions.
*/
cleanupOld(maxAgeMs: number): number {
@@ -410,7 +419,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
.prepare(
`SELECT id FROM ai_sessions
WHERE updatedAt < ?
AND status IN ('complete', 'error', 'generating', 'awaiting_input')`,
AND status IN ('complete', 'error')`,
)
.all(cutoff) as Array<{ id: string }>;
@@ -418,35 +427,59 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
return 0;
}
this.db.transaction(() => {
this.db
.prepare(
`UPDATE ai_sessions
SET status = 'error',
error = CASE
WHEN error IS NULL OR error = '' THEN 'Session expired'
ELSE error
END
WHERE updatedAt < ?
AND status IN ('generating', 'awaiting_input')`,
)
.run(cutoff);
this.db
.prepare(
`DELETE FROM ai_sessions
WHERE updatedAt < ?
AND status IN ('complete', 'error')`,
)
.run(cutoff);
this.db
this.emitDeletedSessions(stale);
return stale.length;
}
/**
* Cleans up stale terminal and orphaned active sessions older than `maxAgeMs`.
*
* - Terminal sessions (`complete`, `error`) are deleted via `cleanupOld()`.
* - Orphaned active sessions (`generating`, `awaiting_input`) are deleted directly.
*/
cleanupStaleSessions(maxAgeMs = SESSION_CLEANUP_DEFAULT_MAX_AGE_MS): AiSessionCleanupSummary {
const terminalDeleted = this.cleanupOld(maxAgeMs);
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
const orphaned = this.db
.prepare(
`SELECT id FROM ai_sessions
WHERE updatedAt < ?
AND status IN ('generating', 'awaiting_input')`,
)
.all(cutoff) as Array<{ id: string }>;
let orphanedDeleted = 0;
if (orphaned.length > 0) {
const result = this.db
.prepare(
`DELETE FROM ai_sessions
WHERE updatedAt < ?
AND status IN ('complete', 'error', 'generating', 'awaiting_input')`,
AND status IN ('generating', 'awaiting_input')`,
)
.run(cutoff);
});
for (const { id } of stale) {
this.clearThinkingTimer(id);
this.emit("ai_session:deleted", id);
.run(cutoff) as { changes?: number };
orphanedDeleted = Number(result.changes ?? 0);
this.emitDeletedSessions(orphaned);
}
return stale.length;
const totalDeleted = terminalDeleted + orphanedDeleted;
console.log(
`[ai-session-store] Cleanup: removed ${terminalDeleted} terminal, ${orphanedDeleted} orphaned sessions`,
);
return {
terminalDeleted,
orphanedDeleted,
totalDeleted,
};
}
/**
@@ -457,10 +490,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
const runCleanup = () => {
try {
const deleted = this.cleanupOld(ttlMs);
if (deleted > 0) {
console.log(`[ai-session-store] Cleaned up ${deleted} stale sessions`);
}
this.cleanupStaleSessions(ttlMs);
} catch (err) {
console.error("[ai-session-store] Scheduled cleanup failed:", err);
}
@@ -483,6 +513,13 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
// ── Internal ────────────────────────────────────────────────────────
private emitDeletedSessions(rows: Array<{ id: string }>): void {
for (const { id } of rows) {
this.clearThinkingTimer(id);
this.emit("ai_session:deleted", id);
}
}
private writeThinking(sessionId: string, thinkingOutput: string): void {
const now = new Date().toISOString();
this.db

View File

@@ -19,6 +19,7 @@ import { __resetPlanningState, __setCreateKbAgent, planningStreamManager } from
import * as planningModule from "./planning.js";
import { __resetSubtaskBreakdownState, subtaskStreamManager } from "./subtask-breakdown.js";
import * as subtaskBreakdownModule from "./subtask-breakdown.js";
import { SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
import * as projectStoreResolver from "./project-store-resolver.js";
import * as terminalServiceModule from "./terminal-service.js";
import { get as performGet, request as performRequest } from "./test-request.js";
@@ -7024,6 +7025,75 @@ describe("Git Management endpoints", () => {
});
});
describe("DELETE /api/ai-sessions/cleanup", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
it("returns cleanup summary with default maxAgeMs", async () => {
const mockAiSessionStore = {
cleanupStaleSessions: vi.fn().mockReturnValue({
terminalDeleted: 5,
orphanedDeleted: 2,
totalDeleted: 7,
}),
};
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { aiSessionStore: mockAiSessionStore as any }));
const res = await REQUEST(app, "DELETE", "/api/ai-sessions/cleanup");
expect(res.status).toBe(200);
expect(res.body).toEqual({
terminalDeleted: 5,
orphanedDeleted: 2,
totalDeleted: 7,
maxAgeMs: SESSION_CLEANUP_DEFAULT_MAX_AGE_MS,
});
expect(mockAiSessionStore.cleanupStaleSessions).toHaveBeenCalledWith(SESSION_CLEANUP_DEFAULT_MAX_AGE_MS);
});
it("respects maxAgeMs override and clamps values below one hour", async () => {
const mockAiSessionStore = {
cleanupStaleSessions: vi.fn().mockReturnValue({
terminalDeleted: 1,
orphanedDeleted: 1,
totalDeleted: 2,
}),
};
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { aiSessionStore: mockAiSessionStore as any }));
const res = await REQUEST(app, "DELETE", "/api/ai-sessions/cleanup?maxAgeMs=1000");
expect(res.status).toBe(200);
expect(res.body).toEqual({
terminalDeleted: 1,
orphanedDeleted: 1,
totalDeleted: 2,
maxAgeMs: 60 * 60 * 1000,
});
expect(mockAiSessionStore.cleanupStaleSessions).toHaveBeenCalledWith(60 * 60 * 1000);
});
it("returns 503 when aiSessionStore is unavailable", async () => {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
const res = await REQUEST(app, "DELETE", "/api/ai-sessions/cleanup");
expect(res.status).toBe(503);
expect(res.body).toEqual({ error: "Session store not available" });
});
});
describe("POST /api/ai-sessions/:id/ping", () => {
let store: TaskStore;

View File

@@ -27,7 +27,7 @@ import {
} from "./github-webhooks.js";
import { createMissionRouter } from "./mission-routes.js";
import { getOrCreateProjectStore } from "./project-store-resolver.js";
import { AiSessionStore } from "./ai-session-store.js";
import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js";
import { getSubtaskSession, cleanupSubtaskSession } from "./subtask-breakdown.js";
import {
@@ -9203,6 +9203,34 @@ Output ONLY the prompt text (no markdown, no explanations).`;
res.json({ sessions });
});
/**
* DELETE /api/ai-sessions/cleanup
* Cleanup stale AI sessions with optional max-age override.
*/
router.delete("/ai-sessions/cleanup", (req, res) => {
if (!aiSessionStore) {
sendErrorResponse(res, 503, "Session store not available");
return;
}
const minimumMaxAgeMs = 60 * 60 * 1000;
let maxAgeMs = SESSION_CLEANUP_DEFAULT_MAX_AGE_MS;
if (typeof req.query.maxAgeMs === "string") {
const parsed = Number(req.query.maxAgeMs);
if (!Number.isFinite(parsed)) {
throw badRequest("maxAgeMs must be a valid number");
}
maxAgeMs = Math.max(minimumMaxAgeMs, Math.floor(parsed));
}
const result = aiSessionStore.cleanupStaleSessions(maxAgeMs);
res.json({
...result,
maxAgeMs,
});
});
/**
* GET /api/ai-sessions/:id
* Get full session state for modal reconnection.

View File

@@ -17,7 +17,11 @@ import { parseBadgeUrl } from "./github.js";
import { WebSocketManager, type BadgeSnapshot } from "./websocket.js";
import type { BadgePubSub } from "./badge-pubsub.js";
import { createBadgePubSub, type BadgePubSubMessage } from "./badge-pubsub.js";
import { AiSessionStore } from "./ai-session-store.js";
import {
AiSessionStore,
SESSION_CLEANUP_DEFAULT_MAX_AGE_MS,
SESSION_CLEANUP_INTERVAL_MS,
} from "./ai-session-store.js";
import {
setAiSessionStore as setPlanningAiSessionStore,
rehydrateFromStore as rehydratePlanningSessions,
@@ -33,14 +37,28 @@ import {
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_AI_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const DEFAULT_AI_SESSION_TTL_MS = SESSION_CLEANUP_DEFAULT_MAX_AGE_MS;
const MIN_AI_SESSION_TTL_MS = 10 * 60 * 1000;
const MAX_AI_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
const DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS = SESSION_CLEANUP_INTERVAL_MS;
const MIN_AI_SESSION_CLEANUP_INTERVAL_MS = 60 * 1000;
const MAX_AI_SESSION_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
let aiSessionCleanupIntervalHandle: ReturnType<typeof setInterval> | undefined;
function clearAiSessionCleanupInterval(): void {
if (!aiSessionCleanupIntervalHandle) {
return;
}
clearInterval(aiSessionCleanupIntervalHandle);
aiSessionCleanupIntervalHandle = undefined;
}
process.on("beforeExit", () => {
clearAiSessionCleanupInterval();
});
export interface ServerOptions {
/** Custom merge handler — when provided, used instead of store.mergeTask */
onMerge?: (taskId: string) => Promise<MergeResult>;
@@ -351,6 +369,26 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
);
}
const runAiSessionCleanup = (maxAgeMs: number, source: "initial" | "scheduled") => {
const result = aiSessionStore.cleanupStaleSessions(maxAgeMs);
console.log(
`[server] AI session cleanup (${source}): removed ${result.terminalDeleted} terminal, ${result.orphanedDeleted} orphaned sessions`,
);
return result;
};
const scheduleAiSessionCleanup = (cleanupIntervalMs: number, maxAgeMs: number) => {
clearAiSessionCleanupInterval();
aiSessionCleanupIntervalHandle = setInterval(() => {
try {
runAiSessionCleanup(maxAgeMs, "scheduled");
} catch (err) {
console.error("[server] Scheduled AI session cleanup failed", err);
}
}, cleanupIntervalMs);
aiSessionCleanupIntervalHandle.unref?.();
};
const loadSettings = (store as { getSettings?: () => Promise<{ aiSessionTtlMs?: number; aiSessionCleanupIntervalMs?: number }> }).getSettings;
if (typeof loadSettings === "function") {
void loadSettings
@@ -368,17 +406,37 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
MIN_AI_SESSION_CLEANUP_INTERVAL_MS,
MAX_AI_SESSION_CLEANUP_INTERVAL_MS,
);
aiSessionStore.startScheduledCleanup(cleanupIntervalMs, ttlMs);
void Promise.resolve()
.then(() => runAiSessionCleanup(ttlMs, "initial"))
.catch((err) => {
console.error("[server] Initial AI session cleanup failed", err);
});
scheduleAiSessionCleanup(cleanupIntervalMs, ttlMs);
})
.catch((err) => {
console.warn("[server] Failed to load settings for AI session cleanup; using defaults", err);
aiSessionStore.startScheduledCleanup(
void Promise.resolve()
.then(() => runAiSessionCleanup(DEFAULT_AI_SESSION_TTL_MS, "initial"))
.catch((cleanupErr) => {
console.error("[server] Initial AI session cleanup failed", cleanupErr);
});
scheduleAiSessionCleanup(
DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS,
DEFAULT_AI_SESSION_TTL_MS,
);
});
} else {
aiSessionStore.startScheduledCleanup(
void Promise.resolve()
.then(() => runAiSessionCleanup(DEFAULT_AI_SESSION_TTL_MS, "initial"))
.catch((err) => {
console.error("[server] Initial AI session cleanup failed", err);
});
scheduleAiSessionCleanup(
DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS,
DEFAULT_AI_SESSION_TTL_MS,
);
@@ -443,6 +501,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
const server = originalListen(...normalizedArgs);
server.once("close", () => {
clearAiSessionCleanupInterval();
aiSessionStore.stopScheduledCleanup();
});