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:
@@ -96,7 +96,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(14);
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -119,7 +119,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(14);
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -726,7 +726,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
|
||||
expect(db.getSchemaVersion()).toBe(14);
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -751,16 +751,16 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(14);
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(14);
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("applies migration 14 by creating agentRatings table and indexes", () => {
|
||||
it("applies migration 14+15 by creating agentRatings and ai_sessions indexes", () => {
|
||||
tmpDir = makeTmpDir();
|
||||
const kbDir = join(tmpDir, ".fusion");
|
||||
|
||||
@@ -771,7 +771,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(14);
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -874,7 +874,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5
|
||||
expect(db.getSchemaVersion()).toBe(14);
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1084,7 +1084,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(14);
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 14;
|
||||
const SCHEMA_VERSION = 15;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -516,6 +516,14 @@ export class Database {
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxAgentRatingsCreatedAt ON agentRatings(createdAt)`);
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 15) {
|
||||
this.applyMigration(15, () => {
|
||||
if (this.hasTable("ai_sessions")) {
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxAiSessionsUpdatedAt ON ai_sessions(updatedAt)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -530,6 +538,16 @@ export class Database {
|
||||
.run(String(targetVersion));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a table exists.
|
||||
*/
|
||||
private hasTable(table: string): boolean {
|
||||
const row = this.db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(table) as { name: string } | undefined;
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a table has a given column.
|
||||
*/
|
||||
|
||||
@@ -863,6 +863,15 @@ export interface ProjectSettings {
|
||||
* than this duration, the task is considered stuck and will be terminated and retried.
|
||||
* Default: undefined (disabled). Suggested value: 600000 (10 minutes). */
|
||||
taskStuckTimeoutMs?: number;
|
||||
/** TTL in milliseconds for persisted AI planning/subtask/mission interview sessions.
|
||||
* Sessions older than this cutoff are expired by the dashboard session cleanup loop.
|
||||
* Valid range: 600000 (10 minutes) to 2592000000 (30 days).
|
||||
* Default: 604800000 (7 days). */
|
||||
aiSessionTtlMs?: number;
|
||||
/** Interval in milliseconds for scheduled AI session cleanup sweeps.
|
||||
* Valid range: 60000 (1 minute) to 86400000 (24 hours).
|
||||
* Default: 3600000 (1 hour). */
|
||||
aiSessionCleanupIntervalMs?: number;
|
||||
/** When true, automatically unpause after rate-limit-triggered globalPause using
|
||||
* escalating backoff. Allows unattended recovery from transient API rate limits.
|
||||
* Default: true. */
|
||||
@@ -1035,6 +1044,8 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
||||
buildTimeoutMs: 300_000,
|
||||
requirePlanApproval: false,
|
||||
taskStuckTimeoutMs: undefined,
|
||||
aiSessionTtlMs: 7 * 24 * 60 * 60 * 1000,
|
||||
aiSessionCleanupIntervalMs: 60 * 60 * 1000,
|
||||
autoUnpauseEnabled: true,
|
||||
autoUnpauseBaseDelayMs: 300_000,
|
||||
autoUnpauseMaxDelayMs: 3_600_000,
|
||||
@@ -1126,6 +1137,8 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
|
||||
"smartConflictResolution",
|
||||
"requirePlanApproval",
|
||||
"taskStuckTimeoutMs",
|
||||
"aiSessionTtlMs",
|
||||
"aiSessionCleanupIntervalMs",
|
||||
"maxStuckKills",
|
||||
"autoUpdatePrStatus",
|
||||
"autoCreatePr",
|
||||
|
||||
Reference in New Issue
Block a user