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:
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user