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:
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -6,7 +6,7 @@ import { join } from "node:path";
|
||||
import { Database } from "@fusion/core";
|
||||
import { AiSessionStore, type AiSessionRow, type AiSessionStatus } from "./ai-session-store.js";
|
||||
|
||||
describe("AiSessionStore.listActive", () => {
|
||||
describe("AiSessionStore", () => {
|
||||
let tmpRoot: string;
|
||||
let db: Database;
|
||||
let store: AiSessionStore;
|
||||
@@ -19,6 +19,8 @@ describe("AiSessionStore.listActive", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
store.stopScheduledCleanup();
|
||||
vi.useRealTimers();
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
@@ -27,7 +29,7 @@ describe("AiSessionStore.listActive", () => {
|
||||
await rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createSession(id: string, status: AiSessionStatus, projectId: string | null = null): AiSessionRow {
|
||||
function makeRow(id: string, status: AiSessionStatus, projectId: string | null = null): AiSessionRow {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
@@ -46,32 +48,155 @@ describe("AiSessionStore.listActive", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("returns generating, awaiting_input, and complete sessions", () => {
|
||||
store.upsert(createSession("S-1", "generating"));
|
||||
store.upsert(createSession("S-2", "awaiting_input"));
|
||||
store.upsert(createSession("S-3", "complete"));
|
||||
store.upsert(createSession("S-4", "error"));
|
||||
function seedSession(params: {
|
||||
id: string;
|
||||
status: AiSessionStatus;
|
||||
ageMs?: number;
|
||||
projectId?: string | null;
|
||||
currentQuestion?: object | null;
|
||||
error?: string | null;
|
||||
}): void {
|
||||
const { id, status, ageMs = 0, projectId = null, currentQuestion = null, error } = params;
|
||||
const row = makeRow(id, status, projectId);
|
||||
row.currentQuestion = currentQuestion ? JSON.stringify(currentQuestion) : null;
|
||||
row.error = error ?? row.error;
|
||||
store.upsert(row);
|
||||
|
||||
const active = store.listActive();
|
||||
const statuses = active.map((session) => session.status).sort();
|
||||
if (ageMs > 0) {
|
||||
const staleTs = new Date(Date.now() - ageMs).toISOString();
|
||||
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?").run(staleTs, id);
|
||||
}
|
||||
}
|
||||
|
||||
expect(statuses).toEqual(["awaiting_input", "complete", "generating"]);
|
||||
expect(active.map((session) => session.id)).toEqual(expect.arrayContaining(["S-1", "S-2", "S-3"]));
|
||||
it("cleanupOld removes stale sessions across all statuses and emits deleted events", () => {
|
||||
const deletedIds: string[] = [];
|
||||
store.on("ai_session:deleted", (id) => deletedIds.push(id));
|
||||
|
||||
seedSession({ id: "S-complete", status: "complete", ageMs: 2 * 60 * 60 * 1000 });
|
||||
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(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"]);
|
||||
});
|
||||
|
||||
it("excludes sessions with error status", () => {
|
||||
store.upsert(createSession("S-err", "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;
|
||||
`);
|
||||
|
||||
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 });
|
||||
|
||||
store.cleanupOld(60 * 60 * 1000);
|
||||
|
||||
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 }>;
|
||||
|
||||
expect(auditRows).toEqual([
|
||||
{
|
||||
id: "S-awaiting",
|
||||
oldStatus: "awaiting_input",
|
||||
newStatus: "error",
|
||||
error: "Session expired",
|
||||
},
|
||||
{
|
||||
id: "S-generating",
|
||||
oldStatus: "generating",
|
||||
newStatus: "error",
|
||||
error: "Session expired",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("startScheduledCleanup and stopScheduledCleanup control cleanup interval", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
seedSession({ id: "S-old", status: "complete", ageMs: 2 * 60 * 1000 });
|
||||
|
||||
store.startScheduledCleanup(1_000, 60_000);
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
expect(store.get("S-old")).toBeNull();
|
||||
|
||||
seedSession({ id: "S-old-2", status: "complete", ageMs: 2 * 60 * 1000 });
|
||||
store.stopScheduledCleanup();
|
||||
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(store.get("S-old-2")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("supports configurable TTL values", () => {
|
||||
seedSession({ id: "S-older", status: "complete", ageMs: 2 * 60 * 60 * 1000 });
|
||||
seedSession({ id: "S-recent", status: "complete", ageMs: 30 * 60 * 1000 });
|
||||
|
||||
const removedWithShortTtl = store.cleanupOld(60 * 60 * 1000);
|
||||
|
||||
expect(removedWithShortTtl).toBe(1);
|
||||
expect(store.get("S-older")).toBeNull();
|
||||
expect(store.get("S-recent")).not.toBeNull();
|
||||
|
||||
const removedWithLongTtl = store.cleanupOld(3 * 60 * 60 * 1000);
|
||||
expect(removedWithLongTtl).toBe(0);
|
||||
});
|
||||
|
||||
it("recoverStaleSessions keeps recoverable sessions and marks unrecoverable ones as error", () => {
|
||||
seedSession({
|
||||
id: "S-recoverable",
|
||||
status: "generating",
|
||||
currentQuestion: { id: "q-1", type: "text", question: "Continue?" },
|
||||
});
|
||||
seedSession({ id: "S-broken", status: "generating", currentQuestion: null });
|
||||
|
||||
const recovered = store.recoverStaleSessions();
|
||||
|
||||
expect(recovered).toBe(2);
|
||||
expect(store.get("S-recoverable")?.status).toBe("awaiting_input");
|
||||
expect(store.get("S-broken")?.status).toBe("error");
|
||||
expect(store.get("S-broken")?.error).toBe("Session interrupted — please restart");
|
||||
});
|
||||
|
||||
it("listActive only returns generating/awaiting_input sessions", () => {
|
||||
seedSession({ id: "S-generating", status: "generating" });
|
||||
seedSession({ id: "S-awaiting", status: "awaiting_input" });
|
||||
seedSession({ id: "S-complete", status: "complete" });
|
||||
seedSession({ id: "S-error", status: "error" });
|
||||
|
||||
const active = store.listActive();
|
||||
|
||||
expect(active).toEqual([]);
|
||||
expect(active.map((session) => session.status).sort()).toEqual(["awaiting_input", "generating"]);
|
||||
expect(active.map((session) => session.id).sort()).toEqual(["S-awaiting", "S-generating"]);
|
||||
});
|
||||
|
||||
it("filters active sessions by projectId", () => {
|
||||
store.upsert(createSession("S-a1", "generating", "project-a"));
|
||||
store.upsert(createSession("S-a2", "complete", "project-a"));
|
||||
store.upsert(createSession("S-b1", "awaiting_input", "project-b"));
|
||||
store.upsert(createSession("S-none", "complete", null));
|
||||
it("listActive filters by projectId", () => {
|
||||
seedSession({ id: "S-a1", status: "generating", projectId: "project-a" });
|
||||
seedSession({ id: "S-a2", status: "awaiting_input", projectId: "project-a" });
|
||||
seedSession({ id: "S-b1", status: "awaiting_input", projectId: "project-b" });
|
||||
seedSession({ id: "S-a-done", status: "complete", projectId: "project-a" });
|
||||
|
||||
const projectA = store.listActive("project-a");
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ const THINKING_DEBOUNCE_MS = 2000;
|
||||
export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
/** Pending debounce timers for thinking-only writes, keyed by session id. */
|
||||
private thinkingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
/** Interval used for periodic stale-session cleanup. */
|
||||
private cleanupTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
constructor(private db: Database) {
|
||||
super();
|
||||
@@ -144,7 +146,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
}
|
||||
|
||||
/**
|
||||
* List active sessions (generating, awaiting_input, or complete).
|
||||
* List active sessions (generating or awaiting_input).
|
||||
* Optionally filtered by projectId.
|
||||
*/
|
||||
listActive(projectId?: string): AiSessionSummary[] {
|
||||
@@ -152,7 +154,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT id, type, status, title, projectId, updatedAt FROM ai_sessions
|
||||
WHERE status IN ('generating', 'awaiting_input', 'complete') AND projectId = ?
|
||||
WHERE status IN ('generating', 'awaiting_input') AND projectId = ?
|
||||
ORDER BY updatedAt DESC`,
|
||||
)
|
||||
.all(projectId) as unknown as AiSessionSummary[];
|
||||
@@ -160,7 +162,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT id, type, status, title, projectId, updatedAt FROM ai_sessions
|
||||
WHERE status IN ('generating', 'awaiting_input', 'complete')
|
||||
WHERE status IN ('generating', 'awaiting_input')
|
||||
ORDER BY updatedAt DESC`,
|
||||
)
|
||||
.all() as unknown as AiSessionSummary[];
|
||||
@@ -209,16 +211,88 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up completed/error sessions older than the given age (ms).
|
||||
* 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.
|
||||
* Returns the number of deleted sessions.
|
||||
*/
|
||||
cleanupOld(maxAgeMs: number): number {
|
||||
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
||||
const result = this.db
|
||||
|
||||
const stale = this.db
|
||||
.prepare(
|
||||
`DELETE FROM ai_sessions WHERE status IN ('complete', 'error') AND updatedAt < ?`,
|
||||
`SELECT id FROM ai_sessions
|
||||
WHERE updatedAt < ?
|
||||
AND status IN ('complete', 'error', 'generating', 'awaiting_input')`,
|
||||
)
|
||||
.run(cutoff);
|
||||
return Number((result as any).changes ?? 0);
|
||||
.all(cutoff) as Array<{ id: string }>;
|
||||
|
||||
if (stale.length === 0) {
|
||||
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', 'generating', 'awaiting_input')`,
|
||||
)
|
||||
.run(cutoff);
|
||||
});
|
||||
|
||||
for (const { id } of stale) {
|
||||
this.clearThinkingTimer(id);
|
||||
this.emit("ai_session:deleted", id);
|
||||
}
|
||||
|
||||
return stale.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic stale-session cleanup using the provided schedule and TTL.
|
||||
*/
|
||||
startScheduledCleanup(cleanupIntervalMs: number, ttlMs: number): void {
|
||||
this.stopScheduledCleanup();
|
||||
|
||||
const runCleanup = () => {
|
||||
try {
|
||||
const deleted = this.cleanupOld(ttlMs);
|
||||
if (deleted > 0) {
|
||||
console.log(`[ai-session-store] Cleaned up ${deleted} stale sessions`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ai-session-store] Scheduled cleanup failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
this.cleanupTimer = setInterval(runCleanup, cleanupIntervalMs);
|
||||
this.cleanupTimer.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic stale-session cleanup if currently running.
|
||||
*/
|
||||
stopScheduledCleanup(): void {
|
||||
if (!this.cleanupTimer) {
|
||||
return;
|
||||
}
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = undefined;
|
||||
}
|
||||
|
||||
// ── Internal ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -43,8 +43,8 @@ const engineReady = initEngine();
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Session TTL in milliseconds (30 minutes) */
|
||||
const SESSION_TTL_MS = 30 * 60 * 1000;
|
||||
/** Session TTL in milliseconds (7 days) */
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Cleanup interval in milliseconds (5 minutes) */
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
@@ -182,9 +182,34 @@ const rateLimits = new Map<string, RateLimitEntry>();
|
||||
// ── AI Session Persistence ────────────────────────────────────────────────
|
||||
|
||||
let _aiSessionStore: AiSessionStore | undefined;
|
||||
let _aiSessionDeletedListener: ((sessionId: string) => void) | undefined;
|
||||
|
||||
export function setAiSessionStore(store: AiSessionStore): void {
|
||||
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||
}
|
||||
|
||||
_aiSessionStore = store;
|
||||
_aiSessionDeletedListener = (sessionId: string) => {
|
||||
cleanupInMemoryMissionSession(sessionId);
|
||||
};
|
||||
_aiSessionStore.on("ai_session:deleted", _aiSessionDeletedListener);
|
||||
}
|
||||
|
||||
function cleanupInMemoryMissionSession(sessionId: string): boolean {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.agent) {
|
||||
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
||||
session.agent = undefined;
|
||||
}
|
||||
|
||||
missionInterviewStreamManager.cleanupSession(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function persistMissionSession(session: MissionInterviewSession, status: "generating" | "awaiting_input" | "complete" | "error", error?: string): void {
|
||||
@@ -223,11 +248,7 @@ function cleanupExpiredSessions(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, session] of sessions) {
|
||||
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
|
||||
if (session.agent) {
|
||||
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
||||
}
|
||||
missionInterviewStreamManager.cleanupSession(id);
|
||||
sessions.delete(id);
|
||||
cleanupInMemoryMissionSession(id);
|
||||
}
|
||||
}
|
||||
for (const [ip, entry] of rateLimits) {
|
||||
@@ -798,18 +819,11 @@ export async function submitMissionInterviewResponse(
|
||||
}
|
||||
|
||||
export async function cancelMissionInterviewSession(sessionId: string): Promise<void> {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
const removed = cleanupInMemoryMissionSession(sessionId);
|
||||
if (!removed) {
|
||||
throw new SessionNotFoundError(`Mission interview session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
if (session.agent) {
|
||||
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
||||
session.agent = undefined;
|
||||
}
|
||||
|
||||
missionInterviewStreamManager.cleanupSession(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
unpersistMissionSession(sessionId);
|
||||
}
|
||||
|
||||
@@ -822,12 +836,7 @@ export function getMissionInterviewSummary(sessionId: string): MissionPlanSummar
|
||||
}
|
||||
|
||||
export function cleanupMissionInterviewSession(sessionId: string): void {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session?.agent) {
|
||||
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
||||
}
|
||||
missionInterviewStreamManager.cleanupSession(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
cleanupInMemoryMissionSession(sessionId);
|
||||
unpersistMissionSession(sessionId);
|
||||
}
|
||||
|
||||
@@ -835,14 +844,18 @@ export function cleanupMissionInterviewSession(sessionId: string): void {
|
||||
* Reset all mission interview state. Used for testing only.
|
||||
*/
|
||||
export function __resetMissionInterviewState(): void {
|
||||
for (const [, session] of sessions) {
|
||||
if (session.agent) {
|
||||
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
||||
}
|
||||
for (const [id] of sessions) {
|
||||
cleanupInMemoryMissionSession(id);
|
||||
}
|
||||
sessions.clear();
|
||||
rateLimits.clear();
|
||||
missionInterviewStreamManager.reset();
|
||||
|
||||
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||
}
|
||||
_aiSessionDeletedListener = undefined;
|
||||
_aiSessionStore = undefined;
|
||||
}
|
||||
|
||||
// ── Custom Errors ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
parseAgentResponse,
|
||||
generateSubtasksFromPlanning,
|
||||
formatInterviewQA,
|
||||
SESSION_TTL_MS,
|
||||
} from "./planning.js";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
|
||||
@@ -483,23 +484,20 @@ describe("planning module", () => {
|
||||
});
|
||||
|
||||
describe("session TTL", () => {
|
||||
it("sessions expire after TTL", async () => {
|
||||
it("uses a 7-day TTL constant", () => {
|
||||
expect(SESSION_TTL_MS).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("does not expire sessions within the old 30-minute window", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
// Verify session exists
|
||||
expect(getSession(sessionId)).toBeDefined();
|
||||
|
||||
// Advance time by 31 minutes
|
||||
// Advance beyond the old 30-minute TTL used prior to FN-1146.
|
||||
vi.advanceTimersByTime(31 * 60 * 1000);
|
||||
|
||||
// Trigger cleanup by creating a new session
|
||||
await createSession(getUniqueIp(), "Another plan", undefined, TEST_ROOT_DIR);
|
||||
|
||||
// Note: Session should be expired after cleanup runs
|
||||
// We can't directly verify as cleanup is async
|
||||
expect(getSession(sessionId)).toBeDefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -95,8 +95,8 @@ For questions:
|
||||
For completion:
|
||||
{\n "type": "complete",\n "data": {\n "title": "Task title",\n "description": "Detailed description",\n "suggestedSize": "S|M|L",\n "suggestedDependencies": [],\n "keyDeliverables": ["Item 1", "Item 2"]\n }\n}`;
|
||||
|
||||
/** Session TTL in milliseconds (30 minutes) */
|
||||
const SESSION_TTL_MS = 30 * 60 * 1000;
|
||||
/** Session TTL in milliseconds (7 days) */
|
||||
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Cleanup interval in milliseconds (5 minutes) */
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
@@ -154,10 +154,39 @@ const rateLimits = new Map<string, RateLimitEntry>();
|
||||
|
||||
/** Optional store for persisting session state across reloads/browsers. */
|
||||
let _aiSessionStore: AiSessionStore | undefined;
|
||||
let _aiSessionDeletedListener: ((sessionId: string) => void) | undefined;
|
||||
|
||||
/** Wire up the AI session persistence store. Called once from server.ts. */
|
||||
export function setAiSessionStore(store: AiSessionStore): void {
|
||||
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||
}
|
||||
|
||||
_aiSessionStore = store;
|
||||
_aiSessionDeletedListener = (sessionId: string) => {
|
||||
cleanupInMemorySession(sessionId);
|
||||
};
|
||||
_aiSessionStore.on("ai_session:deleted", _aiSessionDeletedListener);
|
||||
}
|
||||
|
||||
function cleanupInMemorySession(sessionId: string): boolean {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.agent) {
|
||||
try {
|
||||
session.agent.session.dispose?.();
|
||||
} catch (err) {
|
||||
console.error(`[planning] Error disposing agent for session ${sessionId}:`, err);
|
||||
}
|
||||
session.agent = undefined;
|
||||
}
|
||||
|
||||
planningStreamManager.cleanupSession(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Persist the current session state to SQLite (no-op if store not wired). */
|
||||
@@ -207,8 +236,9 @@ function cleanupExpiredSessions(): void {
|
||||
// Clean up expired sessions
|
||||
for (const [id, session] of sessions) {
|
||||
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
|
||||
sessions.delete(id);
|
||||
cleanedSessions++;
|
||||
if (cleanupInMemorySession(id)) {
|
||||
cleanedSessions++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1144,25 +1174,11 @@ export function formatInterviewQA(
|
||||
* Cancel and cleanup a planning session.
|
||||
*/
|
||||
export async function cancelSession(sessionId: string): Promise<void> {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
const removed = cleanupInMemorySession(sessionId);
|
||||
if (!removed) {
|
||||
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
// Cleanup AI agent if present
|
||||
if (session.agent) {
|
||||
try {
|
||||
session.agent.session.dispose?.();
|
||||
} catch (err) {
|
||||
console.error(`[planning] Error disposing agent for session ${sessionId}:`, err);
|
||||
}
|
||||
session.agent = undefined;
|
||||
}
|
||||
|
||||
// Cleanup SSE subscriptions
|
||||
planningStreamManager.cleanupSession(sessionId);
|
||||
|
||||
sessions.delete(sessionId);
|
||||
unpersistSession(sessionId);
|
||||
}
|
||||
|
||||
@@ -1250,16 +1266,7 @@ export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
|
||||
* Cleanup a session (used after task creation).
|
||||
*/
|
||||
export function cleanupSession(sessionId: string): void {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session?.agent) {
|
||||
try {
|
||||
session.agent.session.dispose?.();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
planningStreamManager.cleanupSession(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
cleanupInMemorySession(sessionId);
|
||||
unpersistSession(sessionId);
|
||||
}
|
||||
|
||||
@@ -1268,18 +1275,18 @@ export function cleanupSession(sessionId: string): void {
|
||||
*/
|
||||
export function __resetPlanningState(): void {
|
||||
// Cleanup all agent sessions
|
||||
for (const [id, session] of sessions) {
|
||||
if (session.agent) {
|
||||
try {
|
||||
session.agent.session.dispose?.();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
for (const [id] of sessions) {
|
||||
cleanupInMemorySession(id);
|
||||
}
|
||||
sessions.clear();
|
||||
rateLimits.clear();
|
||||
planningStreamManager.reset();
|
||||
|
||||
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||
}
|
||||
_aiSessionDeletedListener = undefined;
|
||||
_aiSessionStore = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2111,7 +2111,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe("Agent not found");
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("Attachment routes", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -46,7 +46,7 @@ export type SubtaskStreamEvent =
|
||||
|
||||
export type SubtaskStreamCallback = (event: SubtaskStreamEvent, eventId?: number) => void;
|
||||
|
||||
const SESSION_TTL_MS = 30 * 60 * 1000;
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
const sessions = new Map<string, SubtaskSession & { updatedAt: Date; agent?: any; thinkingOutput: string }>();
|
||||
@@ -54,13 +54,39 @@ const sessions = new Map<string, SubtaskSession & { updatedAt: Date; agent?: any
|
||||
// ── AI Session Persistence ────────────────────────────────────────────────
|
||||
|
||||
let _aiSessionStore: AiSessionStore | undefined;
|
||||
let _aiSessionDeletedListener: ((sessionId: string) => void) | undefined;
|
||||
|
||||
export function setAiSessionStore(store: AiSessionStore): void {
|
||||
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||
}
|
||||
|
||||
_aiSessionStore = store;
|
||||
_aiSessionDeletedListener = (sessionId: string) => {
|
||||
cleanupInMemorySubtaskSession(sessionId);
|
||||
};
|
||||
_aiSessionStore.on("ai_session:deleted", _aiSessionDeletedListener);
|
||||
}
|
||||
|
||||
type SubtaskInternalSession = SubtaskSession & { updatedAt: Date; agent?: any; thinkingOutput: string };
|
||||
|
||||
function cleanupInMemorySubtaskSession(sessionId: string): boolean {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
session.agent?.session?.dispose?.();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
|
||||
subtaskStreamManager.cleanupSession(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function persistSubtaskSession(session: SubtaskInternalSession, status: "generating" | "complete" | "error", error?: string): void {
|
||||
if (!_aiSessionStore) return;
|
||||
const row: AiSessionRow = {
|
||||
@@ -125,13 +151,7 @@ function cleanupExpiredSessions(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, session] of sessions) {
|
||||
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
|
||||
try {
|
||||
session.agent?.session?.dispose?.();
|
||||
} catch {
|
||||
// ignore cleanup failures
|
||||
}
|
||||
sessions.delete(id);
|
||||
subtaskStreamManager.cleanupSession(id);
|
||||
cleanupInMemorySubtaskSession(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,42 +387,30 @@ export function getSubtaskSession(sessionId: string): SubtaskSession | undefined
|
||||
}
|
||||
|
||||
export async function cancelSubtaskSession(sessionId: string): Promise<void> {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
const removed = cleanupInMemorySubtaskSession(sessionId);
|
||||
if (!removed) {
|
||||
throw new SessionNotFoundError(`Subtask session ${sessionId} not found or expired`);
|
||||
}
|
||||
try {
|
||||
session.agent?.session?.dispose?.();
|
||||
} catch {
|
||||
// ignore dispose errors
|
||||
}
|
||||
subtaskStreamManager.cleanupSession(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
unpersistSubtaskSession(sessionId);
|
||||
}
|
||||
|
||||
export function cleanupSubtaskSession(sessionId: string): void {
|
||||
const session = sessions.get(sessionId);
|
||||
try {
|
||||
session?.agent?.session?.dispose?.();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
subtaskStreamManager.cleanupSession(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
cleanupInMemorySubtaskSession(sessionId);
|
||||
unpersistSubtaskSession(sessionId);
|
||||
}
|
||||
|
||||
export function __resetSubtaskBreakdownState(): void {
|
||||
for (const [, session] of sessions) {
|
||||
try {
|
||||
session.agent?.session?.dispose?.();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
for (const [id] of sessions) {
|
||||
cleanupInMemorySubtaskSession(id);
|
||||
}
|
||||
sessions.clear();
|
||||
subtaskStreamManager.reset();
|
||||
|
||||
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||
}
|
||||
_aiSessionDeletedListener = undefined;
|
||||
_aiSessionStore = undefined;
|
||||
}
|
||||
|
||||
export class SessionNotFoundError extends Error {
|
||||
|
||||
Reference in New Issue
Block a user