feat(FN-1146): add configurable AI session cleanup lifecycle

- Add aiSessionTtlMs and aiSessionCleanupIntervalMs project settings with defaults and bounds for cleanup scheduling
- Extend AiSessionStore cleanup to expire stale in-progress sessions, emit deletion events, and support start/stop scheduled cleanup loops
- Wire server startup/shutdown to load cleanup settings and manage the scheduled ai_sessions sweep lifecycle
- Align planning, subtask breakdown, and mission interview in-memory session retention to a 7-day TTL with shared deletion-driven cleanup
- Update schema/tests/docs for migration v15 ai_sessions indexing and end-to-end cleanup/TTL behavior stability
This commit is contained in:
gsxdsm
2026-04-08 06:44:09 -07:00
parent 220c269b0d
commit 00d5f36240
12 changed files with 506 additions and 144 deletions

View File

@@ -23,6 +23,14 @@ import { setAiSessionStore as setMissionAiSessionStore } from "./mission-intervi
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_AI_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
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 MIN_AI_SESSION_CLEANUP_INTERVAL_MS = 60 * 1000;
const MAX_AI_SESSION_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
export interface ServerOptions {
/** Custom merge handler — when provided, used instead of store.mergeTask */
onMerge?: (taskId: string) => Promise<MergeResult>;
@@ -97,6 +105,18 @@ function normalizeListenArgsForTests(args: unknown[]): unknown[] {
return args;
}
function resolveBoundedMs(
value: number | undefined,
fallback: number,
min: number,
max: number,
): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.min(max, Math.max(min, value));
}
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
const app = express();
const mutationRateLimit = rateLimit(RATE_LIMITS.mutation);
@@ -302,6 +322,39 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
setSubtaskAiSessionStore(aiSessionStore);
setMissionAiSessionStore(aiSessionStore);
const loadSettings = (store as { getSettings?: () => Promise<{ aiSessionTtlMs?: number; aiSessionCleanupIntervalMs?: number }> }).getSettings;
if (typeof loadSettings === "function") {
void loadSettings
.call(store)
.then((settings) => {
const ttlMs = resolveBoundedMs(
settings.aiSessionTtlMs,
DEFAULT_AI_SESSION_TTL_MS,
MIN_AI_SESSION_TTL_MS,
MAX_AI_SESSION_TTL_MS,
);
const cleanupIntervalMs = resolveBoundedMs(
settings.aiSessionCleanupIntervalMs,
DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS,
MIN_AI_SESSION_CLEANUP_INTERVAL_MS,
MAX_AI_SESSION_CLEANUP_INTERVAL_MS,
);
aiSessionStore.startScheduledCleanup(cleanupIntervalMs, ttlMs);
})
.catch((err) => {
console.warn("[server] Failed to load settings for AI session cleanup; using defaults", err);
aiSessionStore.startScheduledCleanup(
DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS,
DEFAULT_AI_SESSION_TTL_MS,
);
});
} else {
aiSessionStore.startScheduledCleanup(
DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS,
DEFAULT_AI_SESSION_TTL_MS,
);
}
// REST API
app.use("/api", createApiRoutes(store, { ...options, aiSessionStore }));
@@ -340,6 +393,10 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
const normalizedArgs = normalizeListenArgsForTests(args) as Parameters<typeof originalListen>;
const server = originalListen(...normalizedArgs);
server.once("close", () => {
aiSessionStore.stopScheduledCleanup();
});
if (!dashboardApp.__kbWebSocketsAttached) {
dashboardApp.__kbWebSocketsAttached = true;
setupTerminalWebSocket(dashboardApp, server);